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 版本中的兩個重要優勢
- 所有資料都在單一往返中獲取。
- 客戶端和伺服器已解耦:客戶端指定所需的資料,而不是依賴伺服器端點傳回正確的資料。
對於一個簡單的應用程式來說,這已經是一個很好的改進。
客戶端快取
重複從伺服器重新獲取資訊可能會變得非常慢。例如,從故事列表導覽到列表項目,然後回到故事列表,這意味著我們必須重新獲取整個列表。我們將使用標準解決方案來解決這個問題:快取。
在以資源為導向的 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
已被遞增
query { story(id: "123") { id, text, likeCount } }
現在,我們會看到不同的 likeCount
,這取決於存取故事的方式。使用第一個查詢的視圖會看到過期的計數,而使用第二個查詢的視圖會看到更新的計數。
快取圖形
快取 GraphQL 的解決方案是將階層式回應正規化為記錄的平面集合。Relay 將此快取實作為從 ID 到記錄的對應。每個記錄都是從欄位名稱到欄位值的對應。記錄也可以連結到其他記錄(允許它描述一個循環圖),而這些連結會儲存為特殊的值類型,該類型會參照回最上層的對應。使用這種方法,每個伺服器記錄都會儲存一次,無論它是如何獲取的。
以下是一個範例查詢,它會獲取故事的文字及其作者的名稱
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',
},
};
這只是一個簡單的範例:實際上,快取必須處理一對多的關聯和分頁(以及其他事項)。
使用快取
那麼我們如何使用這個快取呢?讓我們看看兩個操作:接收到回應時寫入快取,以及從快取讀取,以判斷是否可以在本地滿足查詢(與上面的 _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 完全避免網路請求。
快取更新
請注意,這種正規化的快取結構允許快取重疊的結果,而不會重複。每個記錄都會儲存一次,無論它是如何獲取的。讓我們回到先前不一致資料的範例,看看這個快取如何在此情境中提供協助。
第一個查詢是針對故事列表
query { stories { id, text, likeCount } }
使用正規化的回應快取,將會為列表中的每個故事建立一個記錄。stories
欄位會儲存指向每個這些記錄的連結。
第二個查詢重新獲取其中一個故事的資訊
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
)。如果沒有這個策略,即使是最微小的變更,每個視圖都會重新渲染。
請注意,此解決方案也適用於寫入:任何對快取的更新都會通知受影響的視圖,而寫入只是更新快取的另一種方式。
變更
到目前為止,我們已經研究了查詢資料並保持視圖同步更新的過程,但我們還沒有研究寫入。在 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)中的資料變更之間並不總是一對一的關係。隱私就是一個完美的例子:返回一個面向使用者的欄位,例如 age
,可能需要存取我們資料儲存層中的許多記錄,以確定活動使用者是否甚至被允許查看該 age
(我們是朋友嗎?我的年齡有分享嗎?我封鎖你了嗎?等等)。
鑑於這些現實世界的限制,GraphQL 中的方法是讓用戶端查詢在變更後可能會變更的事物。但是我們到底該在該查詢中放入什麼呢?在 Relay 的開發過程中,我們探索了一些想法 — 讓我們簡要地看看它們,以了解為什麼 Relay 使用它所採用的方法
選項 1:重新提取應用程式曾經查詢過的所有內容。即使只有一小部分資料會實際變更,我們仍然必須等待伺服器執行整個查詢,等待下載結果,並等待再次處理它們。這是非常沒有效率的。
選項 2:僅重新提取活動渲染視圖所需的查詢。這比選項 1 略有改進。但是,目前未被檢視的快取資料將不會更新。除非此資料以某種方式標記為過時或從快取中逐出,否則後續查詢將會讀取過時的資訊。
選項 3:重新提取在變更後可能會變更的固定欄位清單。我們將這個清單稱為胖查詢。我們發現這也很沒有效率,因為典型的應用程式僅渲染胖查詢的子集,但這種方法需要提取所有這些欄位。
選項 4 (Relay):重新提取可能變更的內容(胖查詢)和快取中資料的交集。除了資料快取之外,Relay 還會記住用於提取每個項目的查詢。這些稱為追蹤查詢。透過交集追蹤查詢和胖查詢,Relay 可以準確查詢應用程式需要更新的資訊集,不多也不少。
資料提取 API
到目前為止,我們研究了資料提取的較低層面,並了解了各種熟悉的觀念如何轉換為 GraphQL。接下來,讓我們退一步,看看產品開發人員在資料提取方面經常面臨的一些更高層次的關注事項
- 提取視圖階層的所有資料。
- 管理非同步狀態轉換和協調並發請求。
- 管理錯誤。
- 重試失敗的請求。
- 接收查詢/變更回應後更新本機快取。
- 將變更排隊以避免競爭條件。
- 在等待伺服器回應變更時樂觀地更新 UI。
我們發現典型的資料提取方法(使用命令式 API)迫使開發人員處理過多這種非必要的複雜性。例如,考慮樂觀 UI 更新。這是一種在等待伺服器回應時向使用者提供回饋的方式。應該做什麼的邏輯可以很清楚:當使用者按一下「喜歡」時,將文章標記為喜歡並將請求傳送到伺服器。但是實作通常要複雜得多。命令式方法要求我們實作所有這些步驟:觸及 UI 並切換按鈕、啟動網路請求、在必要時重試、在失敗時顯示錯誤(並取消切換按鈕)等等。資料提取也是如此:指定我們需要什麼資料通常會決定如何以及何時提取資料。接下來,我們將探討我們使用 Relay 解決這些問題的方法。
這個頁面有用嗎?
請協助我們讓網站更加完善 回答幾個快速問題.