處理 GraphQL 的 UI 衍生資料
後端在開發 API 時,不可能完全符合前端的期待,因為後端的目的是盡可能的將 API 設計的一般化,這樣能提供更多服務共用 API,也能減輕開發負擔。
前端理所當然就無法避免要處理 UI 衍生資料,我們需要針對 Resposne 的值進行轉換來符合前端的需求,轉換的程式碼很容易就不受控制的侵入進 UI 層,尤其 GraphQL 的 Response 非常彈性,處理起來也十分令人煩躁。
本文章嘗試說明問題與提供解決方式
問題
目前遇到的問題是,後端給出的 Schema 不可能完全符合前端介面開發的需求,因此需要針對 Server 回傳的資料進行轉換:
在下列情景前端都需要進行轉換:
- 型別對 JS 來說不好操作
- 後端的特有的邏輯滲透到前端
- UI 所需的衍生資料
- 沒有為什麼就是想改名
1. 型別對 JS 來說不好操作
我遇過的例子是,時間的格式為 Unix Time in Second,型別為字串,JS Date Object 沒有辦法直接對其處理。
const timeFromServer = "1534476064"
// 直接傳入 UNIX time 字串
new Date(timeFromServer) // => Invalid Date
// 轉成 Int 傳入,會得到錯誤的時間,因為 Date 應接受毫秒
new Date(parseInt(timeFromServer)) // => Mon Jan 19 1970 02:14:36 GMT+0800 (台北標準時間)
// 轉成 Int 傳入,並乘上 1000 才能得到正確的 Date 物件
new Date(parseInt(timeFromServer) * 1000) // => Fri Aug 17 2018 11:21:04 GMT+0800 (台北標準時間)
2. 後端的特有的邏輯滲透到前端
曾遇到的例子是,產品支援使用比特幣(BTC),但是 Golang 的 currency package 並不支援這種幣別,因為某些歷史原因,後端選擇使用白銀(XPT)替代比特幣(BTC),因此在我們後端回傳的資料中,所有的比特幣(BTC)都被叫做白銀(XPT)。
身為一個對使用者體驗有追求的前端,把 XPT 全部轉成 BTC 是很合理的選擇,使用者不應該知道 XPT 跟 BTC 之間的關係。
3. UI 所需的衍生資料
後端提供的資料應該保持純淨,不需要提供衍生資料。但 UI 上會有各種花式變奏或條件判斷需要計算。
例如,後端回傳一筆訂單的資料,包含兩個欄位「訂單總額」與「客戶已付款金額」,在多個地方需要判斷這筆訂單「是否已付清」來顯示對應的 UI。
4. 沒有為什麼就是想改名
有時候回傳的 Key 值名稱不符合個人喜好,就想改個名
如何在前端實作轉換?
所以通常會如何在前端實作轉換呢?
最簡單的做法是,在各個元件裡遇到對應的值都嵌入這些轉換函式。
const Component = () => (
<Query query={queryOrderFromServer}>
{({ data }) => {
// 從 GraphQL Server 拿到訂單的資料
const order = data.order;
return (
<div>
<span>{formateBTC(order.currencyName)}</span>
<span>{formatDate(order.createTime)}</span>
</div>
);
}}
</Query>
);
這樣的做法在訂單資料只出現在系統中一次時沒有任何問題。 但在系統中出現多次時就無法管理,因為同樣的函式會散落在世界各地,或者是不屬於 UI 的邏輯,被寫到 UI 裡。
為了解決這件事,一個頁面或一個區塊一個 Container,我們可以把轉換相關的程式碼提升到 Container 或 HOC。
會寫出 withOrderQuery, withUserQuery 之類的 HOC 或 Container 來負責處理特定資料的轉換,再把資料傳下去改 children,所以每次需要同樣的資料類型,呼叫對應的 HOC 即可。
這種寫法在各個 HOC 之間沒有共用的資料類型時時,可以運作得很好,但是如果各個 HOC 有共用的類型時,就會有重複的程式碼,比如說我們在一個取得 User 資料的 Query,也同時去取得該 User 下的 Orders,這樣 withUserQuery 處理的資料就會包含 Order 這個資料類型,發現了嗎? withUserQuery 與 withOrderQuery 都需要處理 Order 的資料。
在這種情況下,我們為了避免處理 Order 的程式碼的重複,我們需要建立共用的模型 Model,還讓他在各個 HOC 之間共用。
可喜可賀的是,GraphQL 在規劃 Schema 的時候,已經規劃了資料類型,也就是 type,我們可以直接根據 Schema 中的 type,來建立 model。
GraphQL 小知識GraphQL 的運作是這樣的,Server Side 在開發時會定義各個領域物件,在 GraphQL 的標準中,這些領域物件稱之為「Type」,每種「Type」下面會涵蓋多個「Field」,「Field」的作用是作為「Type」的屬性。舉例來說,我們做一個直播系統,可能的 type 有「直播間」、「直播主」、「觀眾」,在 type 「直播間」之下則可能有「直播間名稱」、「直播人數」、「觀眾人數」、「該直播間的直播主」等等的 field
你可以建立像是下面這樣的 class 作為 model(或你想用 function 也可以,這不重要)
class Streamer {
constructor(data) {
this.id = data.id;
this.name = data.name;
this.picture = data.picture;
this.gender = data.gender;
}
get pictureURL() {
if (!this.picture) return null;
return `https://example.com/images/${this.picture}`;
}
get displayedGender() {
return (
{
"0": "Male",
"1": "Female"
}[this.gender] || "Unknown"
);
}
}
就可以在 Container 中,套用該 Streamer 的 model 對資料進行擴充與轉換
import Streamer from "./models/Streamer";
const streamerQuery = gql`
query queryStreamer($id: Int!) {
stream(id: $id) {
id
name
picture
gender
}
}
`;
const streamerContainer = graphql(streamerQuery, {
options: props => ({ variables: { id: props.id } }),
props: ({ data }) => {
return {
transformedData: {
...data,
stream: new Streamer(data.streamer) // 轉一波
}
};
}
});
export default streamerContainer;
乍看之下沒什麼問題,但是假如 query 的資料有好幾層,而 streamer 的資料在深處。
那要如何處理?
先設定一個情境,我們用一個 Query 去拉某「直播網站首頁」所需要的資料,首頁需要顯示排名靠前的「直播間(topLiveRooms)」,並顯示每間直播間的「直播主(Streamer)」資料,以及與該直播間相關的「推薦直播間(recommendedLiveRooms)」,「推薦的直播間」也須顯示「直播主」的資料。
該 Query 的會像下方這個樣子:
query homepageData {
topLiveRooms {
# type TopLiveRoomList
count
items {
# type [LiveRoom]
id
name
streamer {
# type Streamer
name
picture
gender
}
recommendedLiveRooms {
# type RecommendedLiveRoomList
items {
# type [LiveRoom]
name
streamer {
# type Streamer
name
picture
gender
}
}
}
}
}
}
我們用上方的 Query 去 GraphQL Server 拿資料,會得到如下方的 Response
const response = {
topLiveTooms: {
count: 23,
items: [
{
id: 1,
name: "天才的直播間",
streamer: {
id: "phyllis_genius",
name: "Phyllis",
picture: "<https://x.live/avatar/phyllis_genius.png>",
gender: 1
},
recommendedLiveRooms: {
items: [
{
name: "J 神的直播間",
streamer: {
id: "jack_god",
name: "The Jack",
picture: "<https://x.live/avatar/jack_god.png>",
gender: 0
}
}
// other items...
]
}
}
// other items...
]
}
};
如果我們需要在「直播主 Streamer」資料出現的時候,針對包含「直播主 Streamer」資料的 Object,套用我們寫好的 Streamer 模型進行轉換。
大概會寫出如下的程式碼:
import Streamer from "../models/Streamer";
const transformedResponse = {
...response,
topLiveRooms: {
...response.topLiveRooms,
items: response.topLiveRooms.items.map(liveRoom => ({
...liveRoom,
streamer: new Streamer(liveRoom.streamer),
recommendedLiveRooms: {
...liveRoom.recommendedLiveRooms,
items: liveRoom.recommendedLiveRooms.items.map(recommendedLiveRoom => ({
...recommendedLiveRoom,
streamer: new Streamer(recommendedLiveRoom.streamer)
}))
}
}))
}
};
恩…. 醜到炸裂。
就算選擇用 lodash.merge
或忽略 immutability 的方式,也是一樣醜,而且也一樣難寫。
import merge from "lodash/merge";
import Streamer from "../models/Streamer";
const transformedResponse = merge({}, response, {
topLiveRooms: {
items: response.topLiveRooms.items(liveRoom =>
merge({}, liveRoom, {
streamer: new Streamer(liveRoom.streamer),
recommendedLiveRooms: {
items: liveRoom.recommendedLiveRooms.items.map(recommendedLiveRoom =>
merge({}, recommendedLiveRoom, {
streamer: new Streamer(recommendedLiveRoom.streamer)
})
)
}
})
)
}
});
另一種實作的方式是針對每一個 Schema 裡的 Type 在前端都建立一個 model
像下方的程式去描述各個 type 之間的關係:
import Streamer from "./models/Streamer";
class TopLiveRoomList {
constructor(data) {
Object.assign(this, data, {
items: data || data.items.map(item => new LiveRoom(item))
});
}
}
class RecommendedLiveRoomList {
constructor(data) {
Object.assign(this, data, {
items: data || data.items.map(item => new LiveRoom(item))
});
}
}
class LiveRoom {
constructor(data) {
Object.assign(this, data, {
streamer: data.streamer || new Streamer(data.streamer),
recommendedLiveRooms:
data.recommendedLiveRooms ||
new RecommendedLiveRoomList(data.recommendedLiveRooms)
});
}
}
const response = {
/**... 略 ...**/
};
const transformedResponse = {
topLiveRooms: new TopLiveRoomList(response.topLiveRooms)
};
上述作法讓我們可以用非常簡單的方式,去轉換一個複雜 graphql 的 response。
不過缺點就是要寫很多的沒什麼用的中介 model,目的只是為了描述 type 之間的關係。
上述做法都有其缺點。
(我覺得)正確的轉換方式
會如此複雜的根本的原因是,我們必須完整暸解整個結構的形狀,到達待轉換目標的路徑,途經節點是否為空值、物件還是陣列。這對我們開發是一種負擔。
事實上在轉換 Response 中特定 type 的 Object 時,我們根本不需要知道整體的結構。
GraphQL 提供了一系列的 Introspection 語法,作用是 client 端可以跟 server 請求 schema 的結構。其中一個語法是 __typename
。
當在 Query 裡寫入 __typename
,Server 就會把該 type 名稱也一同回傳。
舉例來說:
Query:
query homepageData {
topLiveRooms {
items {
__typename
streamer {
__typename
}
}
__typename
}
}
Response:
const response = {
topLiveRooms: {
items: [
{
__typename: "LiveRoom",
streamer: {
__typename: "Streamer"
}
}
// ...
],
__typename: "TopLiveRoomList"
}
};
程式完全有能力知道目前存取的 Object 屬於哪個 Type,是TopLiveRoomList
, LiveRoom
還是 Streamer
,也能夠實現依據 type 自動套用對應的 Model。
只要將整個 Response 樹遍歷,在存取各個節點時,去查找那個節點的 type 是不是有預先定義好的 model,如果有則使用 defineProperty 加上 getter,這樣就能把預先定義好的 model 套用上去,而且能夠適應各種不同的 response 結構,只需要定義 Model,然後就能享有轉換後的結果。
實作
我依照上方的概念,實作了一個 library graphql-client-models
使用方式如下
import { createTransform } from 'graphql-client-models'
const models = {
Streamer: {
pictureURL: self =>
self.picture ? `https://example.com/images/${self.picture}` : '',
displayedGender: self => {
if (self.gender === 0) return "Male"
if (self.gender === 1) return "Female"
return "Unknown"
}
}
}
const transform = createTransform(models)
定義 Model 們後,依 Model 建立一個轉換函示 transform
我們就可以在 UI 層的某個角落把 transform 函式套用在 response 上
<Query query={topLiveRoomsQuery}>
{({ data }) => {
const result = transform(data)
// 可以在這讀取到轉換過後的值
result.topLiveRooms.items[0].streamer.displayedGender
result.topLiveRooms.items[0].recommendedLiveRooms.items[0].streamer.displayedGender
}}
</Query>
跟上方冗長的轉換方式比起來,是不是看起來簡潔多了呢? 只要定義一次,就可以用在所有地方,而且還不用管 response 的結構! 重點是轉換的邏輯能夠很輕易的被抽離出 UI 層,也很容易針對 model 做單元測試。
結語
在寫類型之間有很多關係的 GraphQL 專案時,要如何漂亮的處理 UI 衍生資料很令人頭疼,偶然靈光一閃 GraphQL 有提供 __typename
,不就可以知道整體的結構了嗎,我寫那麼多判斷式是在…
寫完這個 library 後,開發 GraphQL 整個輕鬆多了呢