GraphQL初體驗,Node.js構建GraphQL API指南

杭州程式設計師張張發表於2020-09-29

image

作者:CHRIS CASTLE
原文:https://blog.heroku.com
譯者:杜尼卜

在過去的幾年中,GraphQL已經成為一種非常流行的API規範,該規範專注於使客戶端(無論客戶端是前端還是第三方)的資料獲取更加容易。

在傳統的基於REST的API方法中,客戶端發出請求,而伺服器決定響應:

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中,客戶端可以精確地確定其從伺服器獲取的資料。例如,客戶端可能只需要使用者名稱和電子郵件,而不需要任何地址資訊:

curl -X POST https://api.heroku.space/graphql -d '
query {
  user(id: 1) {
    name
    email
  }
}


{
  "data":
    {
    "name": "Luke",
    "email": "luke@heroku.space"
    }
}

透過這種新的模式,客戶可以透過縮減響應來滿足他們的需求,從而向伺服器進行更高效的查詢。對於單頁應用(SPA)或其他前端重度客戶端應用,可以透過減少有效載荷大小來加快渲染時間。但是,與任何框架或語言一樣,GraphQL也需要權衡取捨。在本文中,我們將探討使用GraphQL作為API的查詢語言的利弊,以及如何開始構建實現。

為什麼選擇GraphQL?

image

與任何技術決策一樣,瞭解GraphQL為你的專案提供了哪些優勢是很重要的,而不是簡單地因為它是一個流行詞而選擇它。

考慮一個使用API連線到遠端資料庫的SaaS應用程式。你想要呈現使用者的個人資料頁面,你可能需要進行一次API GET 呼叫,以獲取有關使用者的資訊,例如使用者名稱或電子郵件。然後,你可能需要進行另一個API呼叫以獲取有關地址的資訊,該資訊儲存在另一個表中。隨著應用程式的發展,由於其構建方式的原因,你可能需要繼續對不同位置進行更多的API呼叫。雖然每一個API呼叫都可以非同步完成,但你也必須處理它們的響應,無論是錯誤、網路超時,甚至暫停頁面渲染,直到收到所有資料。如上所述,這些響應的有效載荷可能超過了渲染你當前頁面的需要,而且每個API呼叫都有網路延遲,總的延遲加起來可能很可觀。

使用GraphQL,你無需進行多個API呼叫(例如 GET /user/:idGET /user/:id/addresses ),而是進行一次API呼叫並將查詢提交到單個端點:

query {
  user(id: 1) {
    name
    email
    addresses {
    street
    city
    country
    }
  }
}

然後,GraphQL僅提供一個端點來查詢所需的所有域邏輯。如果你的應用程式不斷增長,你會發現自己在你的架構中新增了更多的資料儲存——PostgreSQL可能是儲存使用者資訊的好地方,而Redis可能是儲存其他種類資訊的好地方——對GraphQL端點的一次呼叫將解決所有這些不同的位置,並以他們所請求的資料響應客戶端。

如果你不確定應用程式的需求以及將來如何儲存資料,則GraphQL在這裡也很有用。要修改查詢,你只需新增所需欄位的名稱:

        addresses {
      street
+     apartmentNumber   # new information
      city
      country
    }

這極大地簡化了隨著時間的推移而發展你的應用程式的過程。

定義一個GraphQL schema

有各種程式語言的GraphQL伺服器實現,但在你開始之前,你需要識別你的業務域中的物件,就像任何API一樣。就像REST API可能會使用JSON模式一樣,GraphQL使用SDL或Schema定義語言來定義它的模式,這是一種描述GraphQL API可用的所有物件和欄位的冪等方式。SDL條目的一般格式如下:

type $OBJECT_TYPE {
  $FIELD_NAME($ARGUMENTS): $FIELD_TYPE
}

讓我們以前面的例子為基礎,定義一下user和address的條目是什麼樣子的。

type User {
  name:     String
  email:    String
  addresses:   [Address]
}

type Address {
  street:   String
  city:     String
  country:  String
}

user 定義了兩個 String 欄位,分別是 nameemail ,它還包括一個稱為 addresses 的欄位,它是 Addresses 物件的陣列。 Addresses 還定義了它自己的幾個欄位。 (順便說一下,GraphQL模式不僅有物件,欄位和標量型別,還有更多,你也可以合併介面,聯合和引數,以構建更復雜的模型,但本文中不會介紹。)

我們還需要定義一個型別,這是我們GraphQL API的入口點。你還記得,前面我們說過,GraphQL查詢是這樣的:

query {
  user(id: 1) {
    name
    email
  }
}

query 欄位屬於一種特殊的保留型別,稱為 Query ,這指定了獲取物件的主要入口點。(還有用於修改物件的 Mutation 型別。)在這裡,我們定義了一個 user 欄位,該欄位返回一個 User 物件,因此我們的架構也需要定義此欄位:

type Query {
  user(id: Int!): User
}

type User { ... }
type Address { ... }

欄位中的引數是逗號分隔的列表,格式為 $NAME: $TYPE! 是GraphQL表示該引數是必需的方式,省略表示它是可選的。

根據你選擇的語言,將此模式合併到伺服器中的過程會有所不同,但通常,將資訊用作字串就足夠了。Node.js有 graphql 包來準備GraphQL模式,但我們將使用 graphql-tools 包來代替,因為它提供了一些更多的好處。讓我們匯入該軟體包並閱讀我們的型別定義,以為將來的開發做準備:

const fs = require('fs')
const { makeExecutableSchema } = require("graphql-tools");

let typeDefs = fs.readFileSync("schema.graphql", {
  encoding: "utf8",
  flag: "r",
});

設定解析器

schema設定了構建查詢的方式,但建立schema來定義資料模型只是GraphQL規範的一部分。另一部分涉及實際獲取資料,這是透過使用解析器完成的,解析器是一個返回欄位基礎值的函式。

讓我們看一下如何在Node.js中實現解析器。我們的目的是圍繞著解析器如何與模式一起操作來鞏固概念,所以我們不會圍繞著如何設定資料儲存來做太詳細的介紹。在“現實世界”中,我們可能會使用諸如knex之類的東西建立資料庫連線。現在,讓我們設定一些虛擬資料:

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解析器相當於一個Object,key是要檢索的欄位名,value是返回資料的函式。讓我們從初始 user 按id查詢的一個簡單示例開始:

const resolvers = {
  Query: {
    user: function (parent, { id }) {
      // 使用者查詢邏輯
    },
  },
}

這個解析器需要兩個引數:一個代表父的物件(在最初的根查詢中,這個物件通常是未使用的),一個包含傳遞給你的欄位的引數的JSON物件。並非每個欄位都具有引數,但是在這種情況下,我們將擁有引數,因為我們需要透過使用者ID來檢索其使用者。該函式的其餘部分很簡單:

const resolvers = {
  Query: {
    user: function (_, { id }) {
      return users[id];
    },
  }
}

你會注意到,我們沒有明確定義 UserAddresses 的解析器,graphql-tools 包足夠智慧,可以自動為我們對映這些。如果我們選擇的話,我們可以覆蓋這些,但是現在我們已經定義了我們的型別定義和解析器,我們可以建立我們完整的模式:

const schema = makeExecutableSchema({ typeDefs, resolvers });

執行伺服器

最後,讓我們來執行這個demo吧!因為我們使用的是Express,所以我們可以使用 express-graphql 包來暴露我們的模式作為端點。該程式包需要兩個引數:schema和根value,它有一個可選引數 graphiql,我們將稍後討論。

使用GraphQL中介軟體在你喜歡的埠上設定Express伺服器,如下所示:

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"));

將瀏覽器導航到 http://localhost:5000/graphql,你應該會看到一種IDE介面。在左側窗格中,你可以輸入所需的任何有效GraphQL查詢,而在右側你將獲得結果。

這就是 graphiql: true 所提供的:一種方便的方式來測試你的查詢,你可能不想在生產環境中公開它,但是它使測試變得容易得多。

嘗試輸入上面展示的查詢:

query {
  user(id: 1) {
    name
    email
  }
}

要探索GraphQL的型別化功能,請嘗試為ID引數傳遞一個字串而不是一個整數。

# 這不起作用
query {
  user(id: "1") {
    name
    email
  }
}

你甚至可以嘗試請求不存在的欄位:

# 這不起作用
query {
  user(id: 1) {
    name
    zodiac
  }
}

只需用schema表達幾行清晰的程式碼,就可以在客戶機和伺服器之間建立強型別的契約。這樣可以防止你的服務接收虛假資料,並向請求者清楚地表明錯誤。

效能考量

儘管GraphQL為你解決了很多問題,但它並不能解決構建API的所有固有問題。特別是快取和授權這兩個方面,只是需要一些預案來防止效能問題。GraphQL規範並沒有為實現這兩種方法提供任何指導,這意味著構建它們的責任落在了你身上。

快取

基於REST的API在快取時不需要過度關注,因為它們可以構建在web的其他部分使用的現有HTTP頭策略之上。GraphQL不具有這些快取機制,這會對重複請求造成不必要的處理負擔。考慮以下兩個查詢:

query {
  user(id: 1) {
    name
  }
}

query {
  user(id: 1) {
    email
  }
}

在沒有某種快取的情況下,只是為了檢索兩個不同的列,會導致兩個資料庫查詢來獲取ID為 1User。實際上,由於GraphQL還允許使用別名,因此以下查詢有效,並且還執行兩次查詢:

query {
  one: user(id: 1) {
    name
  }
  two: user(id: 2) {
    name
  }
}

第二個示例暴露了如何批處理查詢的問題。為了快速高效,我們希望GraphQL以儘可能少的往返次數訪問相同的資料庫行。

dataloader程式包旨在解決這兩個問題。給定一個ID陣列,我們將一次性從資料庫中獲取所有這些ID;同樣,後續對同一ID的呼叫也將從快取中獲取該專案。要使用 dataloader 來構建這個,我們需要兩樣東西。首先,我們需要一個函式來載入所有請求的物件。在我們的示例中,看起來像這樣:

const DataLoader = require('dataloader');
const batchGetUserById = async (ids) => {
   // 在現實生活中,這將是資料庫呼叫
  return ids.map(id => users[id]);
};
// userLoader現在是我們的“批次載入功能”
const userLoader = new DataLoader(batchGetUserById);

這樣可以解決批處理的問題。要載入資料並使用快取,我們將使用對 load 方法的呼叫來替換之前的資料查詢,並傳入我們的使用者ID:

const resolvers = {
  Query: {
    user: function (_, { id }) {
      return userLoader.load(id);
    },
  },
}

授權

對於GraphQL來說,授權是一個完全不同的問題。簡而言之,它是識別給定使用者是否有權檢視某些資料的過程。我們可以想象一下這樣的場景:經過認證的使用者可以執行查詢來獲取自己的地址資訊,但應該無法獲取其他使用者的地址。

為了解決這個問題,我們需要修改解析器函式。 除了欄位的引數外,解析器還可以訪問它的父節點,以及傳入的特殊上下文值,這些值可以提供有關當前已認證使用者的資訊。因為我們知道地址是一個敏感欄位,所以我們需要修改我們的程式碼,使對使用者的呼叫不只是返回一個地址列表,而是實際呼叫一些業務邏輯來驗證請求:

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);
    },
  },
};

同樣,我們不需要為每個 User 欄位顯式定義一個解析程式,只需定義一個我們要修改的解析程式即可。

預設情況下,express-graphql 會將當前的HTTP請求作為上下文的值來傳遞,但在設定伺服器時可以更改:

app.use(
  "/graphql",
  express_graphql({
    schema: schema,
    graphiql: true,
    context: {
      currUser: user // 當前經過身份驗證的使用者
    }
  })
);

Schema最佳實踐

GraphQL規範中缺少的一個方面是缺乏對版本控制模式的指導。隨著應用程式的成長和變化,它們的API也會隨之變化,很可能需要刪除或修改GraphQL欄位和物件。但這個缺點也是積極的:透過仔細設計你的GraphQL schema,你可以避免在更容易實現(也更容易破壞)的REST端點中明顯的陷阱,如命名的不一致和混亂的關係。

此外,你應該儘量將業務邏輯與解析器邏輯分開。你的業務邏輯應該是整個應用程式的單一事實來源。在解析器中執行驗證檢查是很有誘惑力的,但隨著模式的增長,這將成為一種難以維持的策略。

GraphQL什麼時候不合適?

GraphQL不能像REST一樣精確地滿足HTTP通訊的需求。例如,無論查詢成功與否,GraphQL僅指定一個狀態碼——200 OK。在這個響應中會返回一個特殊的錯誤鍵,供客戶端解析和識別出錯的地方,因此,錯誤處理可能會有些棘手。

同樣,GraphQL只是一個規範,它不會自動解決你的應用程式面臨的每個問題。效能問題不會消失,資料庫查詢不會變得更快,總的來說,你需要重新思考關於你的API的一切:授權、日誌、監控、快取。版本化你的GraphQL API也可能是一個挑戰,因為官方規範目前不支援處理中斷的變化,這是構建任何軟體不可避免的一部分。如果你有興趣探索GraphQL,你需要投入一些時間來學習如何將其與你的需求進行最佳整合。

瞭解更多

社群圍繞這個新範例聚集,併為前端和後端工程師提供了很棒的GraphQL資源列表。前端和後端工程師都可以使用。你也可以透過在官方的遊樂場上提出真實的請求來檢視查詢和型別是什麼樣子的。

我們還有一個[Code[ish]播客集](https://www.heroku.com/podcas...,專門介紹GraphQL的好處和成本。


微信搜尋【前端全棧開發者】關注這個脫髮、擺攤、賣貨、持續學習的程式設計師的,第一時間閱讀最新文章,會優先兩天發表新文章。關注即可大禮包,準能為你節省不少錢!

相關文章