GraphQL LogoGraphQL

以 GraphQL 包裝 REST API

2016 年 5 月 5 日 by 史蒂文·路舍

我一次又一次從前端網頁和行動裝置開發人員那裡聽到相同的願景:他們渴望利用 Relay 和 GraphQL 等新技術提供的開發人員效率提升,但他們在現有的 REST API 背後投入了多年的心力。在沒有明確證明切換好處的資料下,他們發現很難證明額外投資於 GraphQL 基礎架構是合理的。

在這篇文章中,我將概述一種快速、低投資的方法,你可以使用它來在現有的 REST API 上建立一個 GraphQL 端點,僅使用 JavaScript。在撰寫這篇部落格文章的過程中,沒有任何後端開發人員會受到傷害。

用戶端 REST 封裝器#

我們將建立一個GraphQL 架構,這是一個描述你的資料世界的類型系統,它會封裝對你現有 REST API 的呼叫。此架構將在用戶端接收和解析所有 GraphQL 查詢。此架構具有一些固有的效能缺陷,但實作快速且不需要伺服器變更。

想像一個公開 /people/ 端點的 REST API,你可以透過它瀏覽 Person 模型及其關聯的朋友。

A REST API that exposes an index of people

我們將建立一個 GraphQL 架構,將人及其屬性(例如 first_nameemail)建模為人,以及他們透過友誼與其他人的關聯。

安裝#

首先,我們需要一組架構建置工具。

npm install --save graphql

建立 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 定義的兩件事。首先,我們沒有提供 emailidusername 的解析器。預設解析器僅存取 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 的用戶端 schema#

通常,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 封裝器#

上述示範的客戶端 REST API 封裝器應有助於您快速上手,以便您可以試用 Relay 版本的應用程式(或應用程式的部分)。

然而,正如我們先前提到的,此架構具有一些固有的效能缺陷,原因在於 GraphQL 仍會呼叫您的基礎 REST API,而這可能會非常耗用網路。一個好的下一步是將架構從客戶端移至伺服器端,以將網路延遲降至最低,並讓您有更多能力快取回應。

花接下來的 10 分鐘觀看我使用 Node 和 Express 建立上述 GraphQL 封裝器的伺服器端版本。

加碼回合:真正符合 Relay 的架構#

我們在上面開發的架構會在某個時間點之前適用於 Relay,這個時間點就是您要求 Relay 為您已下載的記錄重新擷取資料時。Relay 的重新擷取子系統仰賴您的 GraphQL 架構公開一個特殊欄位,該欄位可以透過 GUID 擷取資料宇宙中的任何實體。我們稱之為節點介面

要公開節點介面,您需要執行兩件事:在查詢的根目錄提供一個 node(id: String!) 欄位,並將所有 ID 切換為 GUID(全球唯一 ID)。

graphql-relay 套件包含一些輔助函式,讓這件事變得容易。

npm install --save graphql-relay

全域 ID#

首先,讓我們將 PersonTypeid 欄位變更為 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 中的另一組輔助函式將協助我們開發節點欄位。您的工作是提供輔助函式兩個函式

  • 一個函式,可以在給定 GUID 的情況下解析物件。
  • 一個函式,可以在給定物件的情況下解析類型名稱。
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"
}
}
)

上述的物件對類型名稱解析器並非工程奇蹟,但您應該了解這個概念。

接下來,我們只需將 nodeInterfacenodeField 新增到我們的架構中。以下是完整的範例

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 的動作。

Duplicate queries to the REST API

這顯然是我們想避免的!至少,我們需要一種方式來快取這些要求的結果。

我們建立了一個名為 DataLoader 的函式庫,以協助馴服這類查詢。

npm install --save dataloader

特別注意,請確定您的執行時間提供 PromiseMap 的原生或多重填充版本。請在 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 要求

De-duped queries to the REST API

查詢規劃及其他#

請考慮您的 REST API 可能已經提供設定,讓您可以急切載入關聯。也許要載入一個人及其所有直接朋友,您可能會存取網址 /people/1/?include_friends。要在 GraphQL 架構中利用這一點,您需要有能力根據查詢本身的結構 (例如,friends 欄位是否為查詢的一部分) 來開發解析計畫。

對於有興趣了解進階解析策略的最新想法的人,請注意 pull request #304

感謝您的閱讀#

我希望這個示範消除了您與功能性 GraphQL 端點之間的一些障礙,並激勵您在現有專案中實驗 GraphQL 和 Relay。