目前公司正在進行從呼叫 API 的方式改變成使用 GraphQL 的技術轉型中,主要是為了解決前後端型別不一致或是因為後端修改 schema 時,前端並未修改到造成的問題,透過 Graphql-Codegen 的方式搭配 Apollo 使用,自動產生型別以及 hook 去使用,許多的前端相關 Cache 都還沒進行設定,mutation 的相關行為更新資料也大多數是使用 refetchQuery
的方式,而目前正在進行相關的 refactor 和效能改進,不過並不動到原本的 schema,紀錄 2 個有趣遇到的實際 case。
常見的分頁方式分為 offset based 和 cursor based,各有各的好處和適合使用的場景,在 Apollo 中也有提到 2 種 pagination best practice,不過在此次遇到的問題和官網提供的方法不太相同,所以花費了一些時間去處理,不過還是先來分析 2 種分頁方式,再來對我遇到的 case 進行處理。
使用 offset based 的分頁方式最大好處在於使用者可以直接了當的跳去想要查詢的特別頁,並且對於後端來說是非常容易從 DB 去拉取資料的。
但它仍然有些缺點需要去注意的:
OFFSET
進行 query 的時候,在前面的資料仍然會被讀取並跳過他們,所以如果 offset N 的數量過大,也會延遲獲取資料的時間。在 GraphQL Apollo 中提供了 offsetLimitPagination
的 helper function 可以簡單地去處理分頁的 Cache 資料,此 function 的實作方式如下:
先從為什麼要處理分頁 Query 的 Cache 談起,GraphQL 的 query 預設也會以帶的參數當成 Cache 的 key,所以它大概會像是這樣:
Query Cache:
但這並不是分頁時想要儲存的 Cache 形式,而是會希望 Cache 是儲存單一個 Array,這樣的好處很明顯,當刪除一筆或是新增資料的時候,可以直接將新增或是刪除的資料對陣列進行操作,就不會照成刪除了一筆資料,但是卻只顯示同一頁的資料,本該往前補上的資料因為在不同的 Cache Key 上,所以無法自動的取得。並且如上述所提,頁面的資料可能會不相同,會導致 Cache 必須經常更新,或是有著過期的 Cache 資料,若是使用 cache-first
的資料從 Cache 獲取可能就會更不準確。
除了透過 merge
處理 query 的資料如何進行 Cache 處理,可以搭配 read
設定該如何讀取 Cache 的資料,就可以直接進行分頁:
cursor based 的分頁是指定某一 Row 之後的幾項 Item,SQL 會像是(若 id 是無序的話可能另外進行 timestamp 或是 id 順序的 mapping):
使用 cursor 進行分頁的好處在於不會像是 offset based 依據需要 offset 則需要 N 筆資料的時間,並且會解決 offset 資料可能不可靠的問題,因為會指定某特定的項目,會遇到的問題在於 Client 端若不知道特定項目的 cursor,則無法跳到想要跳去的指定範圍。
Apollo 提供了 readField
的 helper function,可以透過 __reference
找到想要讀取的欄位,透過讀取到 id
就可以實現相關的處理,不過工作上遇到的問題是基於 offset based 的分頁,就沒多深入研究了。
首先遇到的 Schema 像是這樣子:
第一點遇到的問題在於此分頁的 arg 是使用 page
和 pageSize
,和官方提供的 offsetLimitPagination
helper function 不太符合,不過這是小問題,將 page
和 pageSize
計算出 offset 重寫就可以了,像是:
第二點是它是回傳一個新的 type,而不是單獨的回傳 Item 的 list,所以若是在 Query 中進行設定反而無法進行設定 Cache,因為其實這個 Resolver 也沒有參數可以使用:
解決方法應該是要在對於有實際傳入 arg 的欄位進行 cache 的處理,並且 FeedItemList
這個 type 並沒有可以當成 key
的欄位,所以對於此 type 應該將它的 keyField
設為 false
:
並且對於 feedItems 欄位應該加上 keyArgs: ["keyword"] ,因為此 Cache 確實會因為 keyword 的不同導致 Cache 應該要更新,不過這樣仍然是有缺點的,因為我們是設置在 type 的 fields 上,在 Query 中並沒有設定該如何處理 feedItemList 這個 query,所以 Cache 會像是:
若是有新的 Cache 要更新,這個 object 就會被直接取代:
等於說每次有不同的 keyword
都會導致它 Cache 重新清除了,不過考量到若在繼續修改應該會花費蠻多時間的,最好方式應該還是從後端將此分頁的 type 重新進行定義會比較好,我就沒繼續對此進行優化了。
對於後端來的資料,有時候會需要前端進行整理或再次經過計算, Apollo 提供了很棒的方法,叫做 Local-only field,我們可以直接透過定義好這個 field 該如何進行運作,接下來只要有人有需要就可以直接進行相關的 query,例如:
我定義了一個 local 的 field,可以直接知道他是不是在購物車中:
若有其他人也需要知道,直接在他的 query 中去 query 這個欄位就可以了,達成了封裝又透過組合的方式進行利用:
若是有使用 codegne 的需要額外定義 client schema 告知 codegen 該 gen 出什麼 type,並且需要在 codegen 的 config 中額外的設定 client schema 的路徑:
有一個資料結構像是如下:
它是一個樹狀的結構,而 descendantTree
則是攤平的所有 node ,裡面包括了所有的孩子、子孫、子子孫....直到 leaf。也就是說當我進行 query,帶有 descendantTree
時,是可以重新組建成樹狀結構的。
在之前為了顯示 Node 的 Path,或是透過 type 進行篩選計算,都需要在前端建立 Tree,如圖若想找到 root 到紅點的 path 或是黃色的 subtree 藍點某一值加起來是多少都需要建完後又再遍歷、篩選、計算,其中可能又會一些公式的不同需要從新建樹又計算的,都算是蠻麻煩的。
而這可以透過 local only field 來解決,並且會提升效能(減少建 Tree)並且若有需要的使用者只需要加上各個 field 就可以了,以想要知道 node 經過的 path 為例來說,可以透過讀取自己本身的 parentId
去找到自己的父親,而父親也遲須依照此方法,並將找到的父親 nodePath 直接進行 concat 就可以了,直接省略了建 Tree,並且可以直接透過 key value 的方式找到父親(O(1)),大概會像是這樣:
若有其他人需要使用到 nodePath
的時候則只需要在 query 的時候新增這個 field 即可:
而其他像是需要計算或是 filter 的值也都可以透過這種方式建立,透過這樣的組合並將邏輯放在一起統一進行管理,有需要就新增 query field,可能可以提升了效能也提升了管理和方便再次重複使用。
有其他方式或是任何的錯誤感謝告知。