GraphQL初體驗(yàn),Node.js構(gòu)建GraphQL API指南
在過(guò)去的幾年中,GraphQL[1]已經(jīng)成為一種非常流行的API規(guī)范,該規(guī)范專注于使客戶端(無(wú)論客戶端是前端還是第三方)的數(shù)據(jù)獲取更加容易。
目錄:
- 為什么選擇GraphQL?
- 定義一個(gè)GraphQL schema
- 設(shè)置解析器
- 運(yùn)行服務(wù)器
- 性能考量
- 緩存
- 授權(quán)
- Schema最佳實(shí)踐
- GraphQL什么時(shí)候不合適?
- 了解更多
在傳統(tǒng)的基于REST的API方法中,客戶端發(fā)出請(qǐng)求,而服務(wù)器決定響應(yīng):
- curl https://api.heroku.space/users/1
- {
- "id": 1,
- "name": "Luke",
- "email": "luke@heroku.space",
- "addresses": [
- {
- "street": "1234 Rodeo Drive",
- "city": "Los Angeles",
- "country": "USA"
- }
- ]
- }
但是,在GraphQL中,客戶端可以精確地確定其從服務(wù)器獲取的數(shù)據(jù)。例如,客戶端可能只需要用戶名和電子郵件,而不需要任何地址信息:
- curl -X POST https://api.heroku.space/graphql -d '
- query {
- user(id: 1) {
- name
- }
- }
- {
- "data":
- {
- "name": "Luke",
- "email": "luke@heroku.space"
- }
- }
通過(guò)這種新的模式,客戶可以通過(guò)縮減響應(yīng)來(lái)滿足他們的需求,從而向服務(wù)器進(jìn)行更高效的查詢。對(duì)于單頁(yè)應(yīng)用(SPA)或其他前端重度客戶端應(yīng)用,可以通過(guò)減少有效載荷大小來(lái)加快渲染時(shí)間。但是,與任何框架或語(yǔ)言一樣,GraphQL也需要權(quán)衡取舍。在本文中,我們將探討使用GraphQL作為API的查詢語(yǔ)言的利弊,以及如何開(kāi)始構(gòu)建實(shí)現(xiàn)。
為什么選擇GraphQL?
與任何技術(shù)決策一樣,了解GraphQL為你的項(xiàng)目提供了哪些優(yōu)勢(shì)是很重要的,而不是簡(jiǎn)單地因?yàn)樗且粋€(gè)流行詞而選擇它。
考慮一個(gè)使用API連接到遠(yuǎn)程數(shù)據(jù)庫(kù)的SaaS應(yīng)用程序。你想要呈現(xiàn)用戶的個(gè)人資料頁(yè)面,你可能需要進(jìn)行一次API GET 調(diào)用,以獲取有關(guān)用戶的信息,例如用戶名或電子郵件。然后,你可能需要進(jìn)行另一個(gè)API調(diào)用以獲取有關(guān)地址的信息,該信息存儲(chǔ)在另一個(gè)表中。隨著應(yīng)用程序的發(fā)展,由于其構(gòu)建方式的原因,你可能需要繼續(xù)對(duì)不同位置進(jìn)行更多的API調(diào)用。雖然每一個(gè)API調(diào)用都可以異步完成,但你也必須處理它們的響應(yīng),無(wú)論是錯(cuò)誤、網(wǎng)絡(luò)超時(shí),甚至?xí)和m?yè)面渲染,直到收到所有數(shù)據(jù)。如上所述,這些響應(yīng)的有效載荷可能超過(guò)了渲染你當(dāng)前頁(yè)面的需要,而且每個(gè)API調(diào)用都有網(wǎng)絡(luò)延遲,總的延遲加起來(lái)可能很可觀。
使用GraphQL,你無(wú)需進(jìn)行多個(gè)API調(diào)用(例如 GET /user/:id 和 GET /user/:id/addresses ),而是進(jìn)行一次API調(diào)用并將查詢提交到單個(gè)端點(diǎn):
- query {
- user(id: 1) {
- name
- addresses {
- street
- city
- country
- }
- }
- }
然后,GraphQL僅提供一個(gè)端點(diǎn)來(lái)查詢所需的所有域邏輯。如果你的應(yīng)用程序不斷增長(zhǎng),你會(huì)發(fā)現(xiàn)自己在你的架構(gòu)中添加了更多的數(shù)據(jù)存儲(chǔ)——PostgreSQL可能是存儲(chǔ)用戶信息的好地方,而Redis可能是存儲(chǔ)其他種類信息的好地方——對(duì)GraphQL端點(diǎn)的一次調(diào)用將解決所有這些不同的位置,并以他們所請(qǐng)求的數(shù)據(jù)響應(yīng)客戶端。
如果你不確定應(yīng)用程序的需求以及將來(lái)如何存儲(chǔ)數(shù)據(jù),則GraphQL在這里也很有用。要修改查詢,你只需添加所需字段的名稱:
- addresses {
- street
- + apartmentNumber # new information
- city
- country
- }
這極大地簡(jiǎn)化了隨著時(shí)間的推移而發(fā)展你的應(yīng)用程序的過(guò)程。
定義一個(gè)GraphQL schema
有各種編程語(yǔ)言的GraphQL服務(wù)器實(shí)現(xiàn),但在你開(kāi)始之前,你需要識(shí)別你的業(yè)務(wù)域中的對(duì)象,就像任何API一樣。就像REST API可能會(huì)使用JSON模式一樣,GraphQL使用SDL或Schema定義語(yǔ)言來(lái)定義它的模式,這是一種描述GraphQL API可用的所有對(duì)象和字段的冪等方式。SDL條目的一般格式如下:
- type $OBJECT_TYPE {
- $FIELD_NAME($ARGUMENTS): $FIELD_TYPE
- }
讓我們以前面的例子為基礎(chǔ),定義一下user和address的條目是什么樣子的。
- type User {
- name: String
- email: String
- addresses: [Address]
- }
- type Address {
- street: String
- city: String
- country: String
- }
user 定義了兩個(gè) String 字段,分別是 name 和 email ,它還包括一個(gè)稱為 addresses 的字段,它是 Addresses 對(duì)象的數(shù)組。Addresses 還定義了它自己的幾個(gè)字段。(順便說(shuō)一下,GraphQL模式不僅有對(duì)象,字段和標(biāo)量類型,還有更多,你也可以合并接口,聯(lián)合和參數(shù),以構(gòu)建更復(fù)雜的模型,但本文中不會(huì)介紹。)
我們還需要定義一個(gè)類型,這是我們GraphQL API的入口點(diǎn)。你還記得,前面我們說(shuō)過(guò),GraphQL查詢是這樣的:
- query {
- user(id: 1) {
- name
- }
- }
該 query 字段屬于一種特殊的保留類型,稱為 Query ,這指定了獲取對(duì)象的主要入口點(diǎn)。(還有用于修改對(duì)象的 Mutation 類型。)在這里,我們定義了一個(gè) user 字段,該字段返回一個(gè) User 對(duì)象,因此我們的架構(gòu)也需要定義此字段:
- type Query {
- user(id: Int!): User
- }
- type User { ... }
- type Address { ... }
字段中的參數(shù)是逗號(hào)分隔的列表,格式為 $NAME: $TYPE。! 是GraphQL表示該參數(shù)是必需的方式,省略表示它是可選的。
根據(jù)你選擇的語(yǔ)言,將此模式合并到服務(wù)器中的過(guò)程會(huì)有所不同,但通常,將信息用作字符串就足夠了。Node.js有 graphql[2] 包來(lái)準(zhǔn)備GraphQL模式,但我們將使用 graphql-tools[3] 包來(lái)代替,因?yàn)樗峁┝艘恍└嗟暮锰帯W屛覀儗?dǎo)入該軟件包并閱讀我們的類型定義,以為將來(lái)的開(kāi)發(fā)做準(zhǔn)備:
- const fs = require('fs')
- const { makeExecutableSchema } = require("graphql-tools");
- let typeDefs = fs.readFileSync("schema.graphql", {
- encoding: "utf8",
- flag: "r",
- });
設(shè)置解析器
schema設(shè)置了構(gòu)建查詢的方式,但建立schema來(lái)定義數(shù)據(jù)模型只是GraphQL規(guī)范的一部分。另一部分涉及實(shí)際獲取數(shù)據(jù),這是通過(guò)使用解析器完成的,解析器是一個(gè)返回字段基礎(chǔ)值的函數(shù)。
讓我們看一下如何在Node.js中實(shí)現(xiàn)解析器。我們的目的是圍繞著解析器如何與模式一起操作來(lái)鞏固概念,所以我們不會(huì)圍繞著如何設(shè)置數(shù)據(jù)存儲(chǔ)來(lái)做太詳細(xì)的介紹。在“現(xiàn)實(shí)世界”中,我們可能會(huì)使用諸如knex[4]之類的東西建立數(shù)據(jù)庫(kù)連接。現(xiàn)在,讓我們?cè)O(shè)置一些虛擬數(shù)據(jù):
- const users = {
- 1: {
- name: "Luke",
- email: "luke@heroku.space",
- addresses: [
- {
- street: "1234 Rodeo Drive",
- city: "Los Angeles",
- country: "USA",
- },
- ],
- },
- 2: {
- name: "Jane",
- email: "jane@heroku.space",
- addresses: [
- {
- street: "1234 Lincoln Place",
- city: "Brooklyn",
- country: "USA",
- },
- ],
- },
- };
Node.js中的GraphQL解析器相當(dāng)于一個(gè)Object,key是要檢索的字段名,value是返回?cái)?shù)據(jù)的函數(shù)。讓我們從初始 user 按id查找的一個(gè)簡(jiǎn)單示例開(kāi)始:
- const resolvers = {
- Query: {
- user: function (parent, { id }) {
- // 用戶查找邏輯
- },
- },
- }
這個(gè)解析器需要兩個(gè)參數(shù):一個(gè)代表父的對(duì)象(在最初的根查詢中,這個(gè)對(duì)象通常是未使用的),一個(gè)包含傳遞給你的字段的參數(shù)的JSON對(duì)象。并非每個(gè)字段都具有參數(shù),但是在這種情況下,我們將擁有參數(shù),因?yàn)槲覀冃枰ㄟ^(guò)用戶ID來(lái)檢索其用戶。該函數(shù)的其余部分很簡(jiǎn)單:
- const resolvers = {
- Query: {
- user: function (_, { id }) {
- return users[id];
- },
- }
- }
你會(huì)注意到,我們沒(méi)有明確定義 User 或 Addresses 的解析器,graphql-tools 包足夠智能,可以自動(dòng)為我們映射這些。如果我們選擇的話,我們可以覆蓋這些,但是現(xiàn)在我們已經(jīng)定義了我們的類型定義和解析器,我們可以建立我們完整的模式:
- const schema = makeExecutableSchema({ typeDefs, resolvers });
運(yùn)行服務(wù)器
最后,讓我們來(lái)運(yùn)行這個(gè)demo吧!因?yàn)槲覀兪褂玫氖荅xpress,所以我們可以使用 express-graphql[5] 包來(lái)暴露我們的模式作為端點(diǎn)。該程序包需要兩個(gè)參數(shù):schema和根value,它有一個(gè)可選參數(shù) graphiql,我們將稍后討論。
使用GraphQL中間件在你喜歡的端口上設(shè)置Express服務(wù)器,如下所示:
- const express = require("express");
- const express_graphql = require("express-graphql");
- const app = express();
- app.use(
- "/graphql",
- express_graphql({
- schema: schema,
- graphiql: true,
- })
- );
- app.listen(5000, () => console.log("Express is now live at localhost:5000"));
將瀏覽器導(dǎo)航到 http://localhost:5000/graphql,你應(yīng)該會(huì)看到一種IDE界面。在左側(cè)窗格中,你可以輸入所需的任何有效GraphQL查詢,而在右側(cè)你將獲得結(jié)果。
這就是 graphiql: true 所提供的:一種方便的方式來(lái)測(cè)試你的查詢,你可能不想在生產(chǎn)環(huán)境中公開(kāi)它,但是它使測(cè)試變得容易得多。
嘗試輸入上面展示的查詢:
- query {
- user(id: 1) {
- name
- }
- }
要探索GraphQL的類型化功能,請(qǐng)嘗試為ID參數(shù)傳遞一個(gè)字符串而不是一個(gè)整數(shù)。
- # 這不起作用
- query {
- user(id: "1") {
- name
- }
- }
你甚至可以嘗試請(qǐng)求不存在的字段:
- # 這不起作用
- query {
- user(id: 1) {
- name
- zodiac
- }
- }
只需用schema表達(dá)幾行清晰的代碼,就可以在客戶機(jī)和服務(wù)器之間建立強(qiáng)類型的契約。這樣可以防止你的服務(wù)接收虛假數(shù)據(jù),并向請(qǐng)求者清楚地表明錯(cuò)誤。
性能考量
盡管GraphQL為你解決了很多問(wèn)題,但它并不能解決構(gòu)建API的所有固有問(wèn)題。特別是緩存和授權(quán)這兩個(gè)方面,只是需要一些預(yù)案來(lái)防止性能問(wèn)題。GraphQL規(guī)范并沒(méi)有為實(shí)現(xiàn)這兩種方法提供任何指導(dǎo),這意味著構(gòu)建它們的責(zé)任落在了你身上。
緩存
基于REST的API在緩存時(shí)不需要過(guò)度關(guān)注,因?yàn)樗鼈兛梢詷?gòu)建在web的其他部分使用的現(xiàn)有HTTP頭策略之上。GraphQL不具有這些緩存機(jī)制,這會(huì)對(duì)重復(fù)請(qǐng)求造成不必要的處理負(fù)擔(dān)。考慮以下兩個(gè)查詢:
- query {
- user(id: 1) {
- name
- }
- }
- query {
- user(id: 1) {
- }
- }
在沒(méi)有某種緩存的情況下,只是為了檢索兩個(gè)不同的列,會(huì)導(dǎo)致兩個(gè)數(shù)據(jù)庫(kù)查詢來(lái)獲取ID為 1 的 User。實(shí)際上,由于GraphQL還允許使用別名,因此以下查詢有效,并且還執(zhí)行兩次查找:
- query {
- one: user(id: 1) {
- name
- }
- two: user(id: 2) {
- name
- }
- }
第二個(gè)示例暴露了如何批處理查詢的問(wèn)題。為了快速高效,我們希望GraphQL以盡可能少的往返次數(shù)訪問(wèn)相同的數(shù)據(jù)庫(kù)行。
dataloader[6]程序包旨在解決這兩個(gè)問(wèn)題。給定一個(gè)ID數(shù)組,我們將一次性從數(shù)據(jù)庫(kù)中獲取所有這些ID;同樣,后續(xù)對(duì)同一ID的調(diào)用也將從緩存中獲取該項(xiàng)目。要使用 dataloader 來(lái)構(gòu)建這個(gè),我們需要兩樣?xùn)|西。首先,我們需要一個(gè)函數(shù)來(lái)加載所有請(qǐng)求的對(duì)象。在我們的示例中,看起來(lái)像這樣:
- const DataLoader = require('dataloader');
- const batchGetUserById = async (ids) => {
- // 在現(xiàn)實(shí)生活中,這將是數(shù)據(jù)庫(kù)調(diào)用
- return ids.map(id => users[id]);
- };
- // userLoader現(xiàn)在是我們的“批量加載功能”
- const userLoader = new DataLoader(batchGetUserById);
這樣可以解決批處理的問(wèn)題。要加載數(shù)據(jù)并使用緩存,我們將使用對(duì) load 方法的調(diào)用來(lái)替換之前的數(shù)據(jù)查找,并傳入我們的用戶ID:
- const resolvers = {
- Query: {
- user: function (_, { id }) {
- return userLoader.load(id);
- },
- },
- }
授權(quán)
對(duì)于GraphQL來(lái)說(shuō),授權(quán)是一個(gè)完全不同的問(wèn)題。簡(jiǎn)而言之,它是識(shí)別給定用戶是否有權(quán)查看某些數(shù)據(jù)的過(guò)程。我們可以想象一下這樣的場(chǎng)景:經(jīng)過(guò)認(rèn)證的用戶可以執(zhí)行查詢來(lái)獲取自己的地址信息,但應(yīng)該無(wú)法獲取其他用戶的地址。
為了解決這個(gè)問(wèn)題,我們需要修改解析器函數(shù)。除了字段的參數(shù)外,解析器還可以訪問(wèn)它的父節(jié)點(diǎn),以及傳入的特殊上下文值,這些值可以提供有關(guān)當(dāng)前已認(rèn)證用戶的信息。因?yàn)槲覀冎赖刂肥且粋€(gè)敏感字段,所以我們需要修改我們的代碼,使對(duì)用戶的調(diào)用不只是返回一個(gè)地址列表,而是實(shí)際調(diào)用一些業(yè)務(wù)邏輯來(lái)驗(yàn)證請(qǐng)求:
- const getAddresses = function(currUser, user) {
- if (currUser.id == user.id) {
- return user.addresses
- }
- return [];
- }
- const resolvers = {
- Query: {
- user: function (_, { id }) {
- return users[id];
- },
- },
- User: {
- addresses: function (parentObj, {}, context) {
- return getAddresses(context.currUser, parentObj);
- },
- },
- };
同樣,我們不需要為每個(gè) User 字段顯式定義一個(gè)解析程序,只需定義一個(gè)我們要修改的解析程序即可。
默認(rèn)情況下,express-graphql 會(huì)將當(dāng)前的HTTP請(qǐng)求作為上下文的值來(lái)傳遞,但在設(shè)置服務(wù)器時(shí)可以更改:
- app.use(
- "/graphql",
- express_graphql({
- schema: schema,
- graphiql: true,
- context: {
- currUser: user // 當(dāng)前經(jīng)過(guò)身份驗(yàn)證的用戶
- }
- })
- );
Schema最佳實(shí)踐
GraphQL規(guī)范中缺少的一個(gè)方面是缺乏對(duì)版本控制模式的指導(dǎo)。隨著應(yīng)用程序的成長(zhǎng)和變化,它們的API也會(huì)隨之變化,很可能需要?jiǎng)h除或修改GraphQL字段和對(duì)象。但這個(gè)缺點(diǎn)也是積極的:通過(guò)仔細(xì)設(shè)計(jì)你的GraphQL schema,你可以避免在更容易實(shí)現(xiàn)(也更容易破壞)的REST端點(diǎn)中明顯的陷阱,如命名的不一致和混亂的關(guān)系。
此外,你應(yīng)該盡量將業(yè)務(wù)邏輯與解析器邏輯分開(kāi)。你的業(yè)務(wù)邏輯應(yīng)該是整個(gè)應(yīng)用程序的單一事實(shí)來(lái)源。在解析器中執(zhí)行驗(yàn)證檢查是很有誘惑力的,但隨著模式的增長(zhǎng),這將成為一種難以維持的策略。
GraphQL什么時(shí)候不合適?
GraphQL不能像REST一樣精確地滿足HTTP通信的需求。例如,無(wú)論查詢成功與否,GraphQL僅指定一個(gè)狀態(tài)碼——200 OK。在這個(gè)響應(yīng)中會(huì)返回一個(gè)特殊的錯(cuò)誤鍵,供客戶端解析和識(shí)別出錯(cuò)的地方,因此,錯(cuò)誤處理可能會(huì)有些棘手。
同樣,GraphQL只是一個(gè)規(guī)范,它不會(huì)自動(dòng)解決你的應(yīng)用程序面臨的每個(gè)問(wèn)題。性能問(wèn)題不會(huì)消失,數(shù)據(jù)庫(kù)查詢不會(huì)變得更快,總的來(lái)說(shuō),你需要重新思考關(guān)于你的API的一切:授權(quán)、日志、監(jiān)控、緩存。版本化你的GraphQL API也可能是一個(gè)挑戰(zhàn),因?yàn)楣俜揭?guī)范目前不支持處理中斷的變化,這是構(gòu)建任何軟件不可避免的一部分。如果你有興趣探索GraphQL,你需要投入一些時(shí)間來(lái)學(xué)習(xí)如何將其與你的需求進(jìn)行最佳整合。
本文轉(zhuǎn)載自微信公眾號(hào)「前端全棧開(kāi)發(fā)者」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系前端全棧開(kāi)發(fā)者公眾號(hào)。