2016 年 5 月 5 日 by 史蒂文·路舍
我一次又一次從前端網頁和行動裝置開發人員那裡聽到相同的願景:他們渴望利用 Relay 和 GraphQL 等新技術提供的開發人員效率提升,但他們在現有的 REST API 背後投入了多年的心力。在沒有明確證明切換好處的資料下,他們發現很難證明額外投資於 GraphQL 基礎架構是合理的。
在這篇文章中,我將概述一種快速、低投資的方法,你可以使用它來在現有的 REST API 上建立一個 GraphQL 端點,僅使用 JavaScript。在撰寫這篇部落格文章的過程中,沒有任何後端開發人員會受到傷害。
我們將建立一個GraphQL 架構,這是一個描述你的資料世界的類型系統,它會封裝對你現有 REST API 的呼叫。此架構將在用戶端接收和解析所有 GraphQL 查詢。此架構具有一些固有的效能缺陷,但實作快速且不需要伺服器變更。
想像一個公開 /people/
端點的 REST API,你可以透過它瀏覽 Person
模型及其關聯的朋友。
我們將建立一個 GraphQL 架構,將人及其屬性(例如 first_name
和 email
)建模為人,以及他們透過友誼與其他人的關聯。
首先,我們需要一組架構建置工具。
npm install --save graphql
最終,我們希望匯出一個 GraphQLSchema
,我們可以使用它來解析查詢。
import { GraphQLSchema } from "graphql"
export default new GraphQLSchema({ query: QueryType,})
所有 GraphQL 架構的根部是一個稱為 query
的類型,我們提供其定義,並在此指定為 QueryType
。現在讓我們建立 QueryType
,這是一個類型,我們將在上面定義所有可能想要擷取的項目。
為了複製 REST API 的所有功能,我們在 QueryType
上公開兩個欄位
allPeople
欄位,類似於 /people/
person(id: String)
欄位,類似於 /people/{ID}/
每個欄位都將包含一個回傳類型、選用的引數定義和一個解析所查詢資料的 JavaScript 方法。
import { GraphQLList, GraphQLObjectType, GraphQLString,} from 'graphql';
const QueryType = new GraphQLObjectType({ name: 'Query', description: 'The root of all... queries', fields: () => ({ allPeople: { type: new GraphQLList(PersonType), resolve: root => // Fetch the index of people from the REST API, }, person: { type: PersonType, args: { id: { type: GraphQLString }, }, resolve: (root, args) => // Fetch the person with ID `args.id`, }, }),});
讓我們先將解析器留為草稿,然後繼續定義 PersonType
。
import { GraphQLList, GraphQLObjectType, GraphQLString,} from 'graphql';
const PersonType = new GraphQLObjectType({ name: 'Person', description: 'Somebody that you used to know', fields: () => ({ firstName: { type: GraphQLString, resolve: person => person.first_name, }, lastName: { type: GraphQLString, resolve: person => person.last_name, }, email: {type: GraphQLString}, id: {type: GraphQLString}, username: {type: GraphQLString}, friends: { type: new GraphQLList(PersonType), resolve: person => // Fetch the friends with the URLs `person.friends`, }, }),});
請注意關於 PersonType
定義的兩件事。首先,我們沒有提供 email
、id
或 username
的解析器。預設解析器僅存取 person
物件的屬性,其名稱與欄位相同。這適用於所有地方,除了屬性名稱與欄位名稱不符(例如,欄位 firstName
與 REST API 回應物件的 first_name
屬性不符)或存取屬性不會產生我們想要的物件(例如,我們想要 friends
欄位的個人物件清單,而不是 URL 清單)。
現在,讓我們撰寫從 REST API 擷取人員的解析器。由於我們需要從網路載入,因此我們無法立即傳回值。對我們來說很幸運,resolve()
可以傳回值或值的 Promise
。我們將利用此優點向 REST API 發出 HTTP 要求,最終解析為符合 PersonType
的 JavaScript 物件。
我們在此提供一個完整的 schema 初步版本
import { GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString,} from 'graphql';
const BASE_URL = 'https://myapp.com/';
function fetchResponseByURL(relativeURL) { return fetch(`${BASE_URL}${relativeURL}`).then(res => res.json());}
function fetchPeople() { return fetchResponseByURL('/people/').then(json => json.people);}
function fetchPersonByURL(relativeURL) { return fetchResponseByURL(relativeURL).then(json => json.person);}
const PersonType = new GraphQLObjectType({ /* ... */ fields: () => ({ /* ... */ friends: { type: new GraphQLList(PersonType), resolve: person => person.friends.map(fetchPersonByURL), }, }),});
const QueryType = new GraphQLObjectType({ /* ... */ fields: () => ({ allPeople: { type: new GraphQLList(PersonType), resolve: fetchPeople, }, person: { type: PersonType, args: { id: { type: GraphQLString }, }, resolve: (root, args) => fetchPersonByURL(`/people/${args.id}/`), }, }),});
export default new GraphQLSchema({ query: QueryType,});
通常,Relay 會透過 HTTP 將其 GraphQL 查詢傳送至伺服器。我們可以注入 @taion 的自訂 relay-local-schema
網路層,以使用我們剛剛建立的 schema 解析查詢。將此程式碼放在保證在掛載 Relay 應用程式之前執行的任何位置。
npm install --save relay-local-schema
import RelayLocalSchema from 'relay-local-schema'; import schema from './schema'; Relay.injectNetworkLayer( new RelayLocalSchema.NetworkLayer({ schema }));
這樣就完成了。Relay 會將所有查詢傳送至您的自訂客戶端常駐架構,而架構會透過呼叫現有的 REST API 來解析查詢。
上述示範的客戶端 REST API 封裝器應有助於您快速上手,以便您可以試用 Relay 版本的應用程式(或應用程式的部分)。
然而,正如我們先前提到的,此架構具有一些固有的效能缺陷,原因在於 GraphQL 仍會呼叫您的基礎 REST API,而這可能會非常耗用網路。一個好的下一步是將架構從客戶端移至伺服器端,以將網路延遲降至最低,並讓您有更多能力快取回應。
花接下來的 10 分鐘觀看我使用 Node 和 Express 建立上述 GraphQL 封裝器的伺服器端版本。
我們在上面開發的架構會在某個時間點之前適用於 Relay,這個時間點就是您要求 Relay 為您已下載的記錄重新擷取資料時。Relay 的重新擷取子系統仰賴您的 GraphQL 架構公開一個特殊欄位,該欄位可以透過 GUID 擷取資料宇宙中的任何實體。我們稱之為節點介面。
要公開節點介面,您需要執行兩件事:在查詢的根目錄提供一個 node(id: String!)
欄位,並將所有 ID 切換為 GUID(全球唯一 ID)。
graphql-relay
套件包含一些輔助函式,讓這件事變得容易。
npm install --save graphql-relay
首先,讓我們將 PersonType
的 id
欄位變更為 GUID。為此,我們將使用 graphql-relay
中的 globalIdField
輔助函式。
import { globalIdField } from "graphql-relay"
const PersonType = new GraphQLObjectType({ name: "Person", description: "Somebody that you used to know", fields: () => ({ id: globalIdField("Person"), /* ... */ }),})
globalIdField
在幕後會傳回一個欄位定義,將 id
解析為 GraphQLString
,方法是將類型名稱 'Person'
和 REST API 傳回的 ID 雜湊在一起。我們稍後可以使用 fromGlobalId
將此欄位的結果轉換回 'Person'
和 REST API 的 ID。
graphql-relay
中的另一組輔助函式將協助我們開發節點欄位。您的工作是提供輔助函式兩個函式
import { fromGlobalId, nodeDefinitions } from "graphql-relay"
const { nodeInterface, nodeField } = nodeDefinitions( globalId => { const { type, id } = fromGlobalId(globalId) if (type === "Person") { return fetchPersonByURL(`/people/${id}/`) } }, object => { if (object.hasOwnProperty("username")) { return "Person" } })
上述的物件對類型名稱解析器並非工程奇蹟,但您應該了解這個概念。
接下來,我們只需將 nodeInterface
和 nodeField
新增到我們的架構中。以下是完整的範例
import { GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString,} from 'graphql';import { fromGlobalId, globalIdField, nodeDefinitions,} from 'graphql-relay';
const BASE_URL = 'https://myapp.com/';
function fetchResponseByURL(relativeURL) { return fetch(`${BASE_URL}${relativeURL}`).then(res => res.json());}
function fetchPeople() { return fetchResponseByURL('/people/').then(json => json.people);}
function fetchPersonByURL(relativeURL) { return fetchResponseByURL(relativeURL).then(json => json.person);}
const { nodeInterface, nodeField } = nodeDefinitions( globalId => { const { type, id } = fromGlobalId(globalId); if (type === 'Person') { return fetchPersonByURL(`/people/${id}/`); } }, object => { if (object.hasOwnProperty('username')) { return 'Person'; } },);
const PersonType = new GraphQLObjectType({ name: 'Person', description: 'Somebody that you used to know', fields: () => ({ firstName: { type: GraphQLString, resolve: person => person.first_name, }, lastName: { type: GraphQLString, resolve: person => person.last_name, }, email: {type: GraphQLString}, id: globalIdField('Person'), username: {type: GraphQLString}, friends: { type: new GraphQLList(PersonType), resolve: person => person.friends.map(fetchPersonByURL), }, }), interfaces: [ nodeInterface ],});
const QueryType = new GraphQLObjectType({ name: 'Query', description: 'The root of all... queries', fields: () => ({ allPeople: { type: new GraphQLList(PersonType), resolve: fetchPeople, }, node: nodeField, person: { type: PersonType, args: { id: { type: GraphQLString }, }, resolve: (root, args) => fetchPersonByURL(`/people/${args.id}/`), }, }),});
export default new GraphQLSchema({ query: QueryType,});
考量下列朋友的朋友的朋友查詢
query { person(id: "1") { firstName friends { firstName friends { firstName friends { firstName } } } }}
我們在上面建立的架構將為相同的資料產生多重往返 REST API 的動作。
這顯然是我們想避免的!至少,我們需要一種方式來快取這些要求的結果。
我們建立了一個名為 DataLoader 的函式庫,以協助馴服這類查詢。
npm install --save dataloader
特別注意,請確定您的執行時間提供 Promise
和 Map
的原生或多重填充版本。請在 DataLoader 網站上閱讀更多相關資訊。
要建立一個 DataLoader
,您提供一個方法,該方法可以在給定一串金鑰的情況下,解析一串物件。在我們的範例中,金鑰是我們用來存取 REST API 的網址。
const personLoader = new DataLoader(urls => Promise.all(urls.map(fetchPersonByURL)))
如果這個資料載入器在其生命週期中看到一個金鑰超過一次,它會傳回回應的備忘 (快取) 版本。
我們可以使用 personLoader
上的 load()
和 loadMany()
方法來載入網址,而不用擔心會重複存取 REST API 超過一次。以下是完整的範例
import DataLoader from 'dataloader';import { GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString,} from 'graphql';import { fromGlobalId, globalIdField, nodeDefinitions,} from 'graphql-relay';
const BASE_URL = 'https://myapp.com/';
function fetchResponseByURL(relativeURL) { return fetch(`${BASE_URL}${relativeURL}`).then(res => res.json());}
function fetchPeople() { return fetchResponseByURL('/people/').then(json => json.people);}
function fetchPersonByURL(relativeURL) { return fetchResponseByURL(relativeURL).then(json => json.person);}
const personLoader = new DataLoader( urls => Promise.all(urls.map(fetchPersonByURL)));
const { nodeInterface, nodeField } = nodeDefinitions( globalId => { const {type, id} = fromGlobalId(globalId); if (type === 'Person') { return personLoader.load(`/people/${id}/`); } }, object => { if (object.hasOwnProperty('username')) { return 'Person'; } },);
const PersonType = new GraphQLObjectType({ name: 'Person', description: 'Somebody that you used to know', fields: () => ({ firstName: { type: GraphQLString, resolve: person => person.first_name, }, lastName: { type: GraphQLString, resolve: person => person.last_name, }, email: {type: GraphQLString}, id: globalIdField('Person'), username: {type: GraphQLString}, friends: { type: new GraphQLList(PersonType), resolve: person => personLoader.loadMany(person.friends), }, }), interfaces: [nodeInterface],});
const QueryType = new GraphQLObjectType({ name: 'Query', description: 'The root of all... queries', fields: () => ({ allPeople: { type: new GraphQLList(PersonType), resolve: fetchPeople, }, node: nodeField, person: { type: PersonType, args: { id: { type: GraphQLString }, }, resolve: (root, args) => personLoader.load(`/people/${args.id}/`), }, }),});
export default new GraphQLSchema({ query: QueryType,});
現在,我們的病態查詢會產生以下一組經過良好重複資料刪除的 REST API 要求
請考慮您的 REST API 可能已經提供設定,讓您可以急切載入關聯。也許要載入一個人及其所有直接朋友,您可能會存取網址 /people/1/?include_friends
。要在 GraphQL 架構中利用這一點,您需要有能力根據查詢本身的結構 (例如,friends
欄位是否為查詢的一部分) 來開發解析計畫。
對於有興趣了解進階解析策略的最新想法的人,請注意 pull request #304。
我希望這個示範消除了您與功能性 GraphQL 端點之間的一些障礙,並激勵您在現有專案中實驗 GraphQL 和 Relay。