創建您的第一個 GraphQL API
已發表: 2022-03-11前言
幾年前,Facebook 引入了一種構建後端 API 的新方法,稱為 GraphQL,它基本上是一種用於數據查詢和操作的特定領域語言。 起初,我並沒有太在意它,但最終,我發現自己參與了 Toptal 的一個項目,在那裡我必須實現基於 GraphQL 的後端 API。 就在那時,我開始學習如何將我為 REST 學到的知識應用到 GraphQL。
這是一次非常有趣的經歷,在實施期間,我不得不以更加 GraphQL 友好的方式重新思考 REST API 中使用的標準方法和方法。 在本文中,我嘗試總結首次實現 GraphQL API 時要考慮的常見問題。
所需的庫
GraphQL 由 Facebook 內部開發並於 2015 年公開發布。2018 年晚些時候,GraphQL 項目從 Facebook 轉移到新成立的 GraphQL 基金會,由非營利性 Linux 基金會託管,該基金會維護和開發 GraphQL 查詢語言規範和參考JavaScript 的實現。
由於 GraphQL 仍然是一項年輕的技術,並且最初的參考實現可用於 JavaScript,因此大多數成熟的庫都存在於 Node.js 生態系統中。 還有另外兩家公司 Apollo 和 Prisma 為 GraphQL 提供開源工具和庫。 本文中的示例項目將基於這兩家公司提供的 GraphQL for JavaScript 和庫的參考實現:
- Graphql-js – GraphQL for JavaScript 的參考實現
- Apollo-server – 用於 Express、Connect、Hapi、Koa 等的 GraphQL 服務器
- Apollo-graphql-tools – 使用 SDL 構建、模擬和拼接 GraphQL 模式
- Prisma-graphql-middleware – 在中間件函數中拆分你的 GraphQL 解析器
在 GraphQL 世界中,您使用 GraphQL 模式來描述您的 API,為此,規範定義了自己的語言,稱為 GraphQL 模式定義語言 (SDL)。 SDL 使用起來非常簡單直觀,同時功能強大且富有表現力。
存在兩種創建 GraphQL 模式的方法:代碼優先方法和模式優先方法。
- 在代碼優先方法中,您將 GraphQL 模式描述為基於 graphql-js 庫的 JavaScript 對象,並且 SDL 是從源代碼自動生成的。
- 在模式優先的方法中,您在 SDL 中描述您的 GraphQL 模式,並使用 Apollo graphql-tools 庫連接您的業務邏輯。
就個人而言,我更喜歡模式優先的方法,並將在本文的示例項目中使用它。 我們將實現一個經典的書店示例並創建一個後端,該後端將提供 CRUD API 來創建作者和書籍以及用於用戶管理和身份驗證的 API。
創建一個基本的 GraphQL 服務器
要運行基本的 GraphQL 服務器,我們必須創建一個新項目,使用 npm 對其進行初始化,並配置 Babel。 要配置 Babel,首先使用以下命令安裝所需的庫:
npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/node
安裝 Babel 後,在我們項目的根目錄中創建一個名為.babelrc
的文件,並在那裡複製以下配置:
{ "presets": [ [ "@babel/env", { "targets": { "node": "current" } } ] ] }
還要編輯package.json
文件並將以下命令添加到scripts
部分:
{ ... "scripts": { "serve": "babel-node index.js" }, ... }
配置 Babel 後,使用以下命令安裝所需的 GraphQL 庫:
npm install --save express apollo-server-express graphql graphql-tools graphql-tag
安裝所需的庫後,要以最少的設置運行 GraphQL 服務器,請將以下代碼片段複製到我們的index.js
文件中:
import gql from 'graphql-tag'; import express from 'express'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-express'; const port = process.env.PORT || 8080; // Define APIs using GraphQL SDL const typeDefs = gql` type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } }; // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolvers maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });
之後,我們可以使用命令npm run serve
運行我們的服務器,如果我們在 Web 瀏覽器中導航到 URL http://localhost:8080/graphql
,GraphQL 的交互式可視化 shell(稱為 Playground)將打開,我們可以在其中執行 GraphQL 查詢和突變並查看結果數據。
在 GraphQL 世界中,API 函數分為三組,分別稱為查詢、突變和訂閱:
- 客戶端使用查詢從服務器請求它需要的數據。
- 客戶端使用突變在服務器上創建/更新/刪除數據。
- 客戶端使用訂閱來創建和維護與服務器的實時連接。 這使客戶端能夠從服務器獲取事件並採取相應的行動。
在我們的文章中,我們將只討論查詢和突變。 訂閱是一個巨大的話題——它們應該有自己的文章,並且不是每個 API 實現都需要的。
高級標量數據類型
玩過 GraphQL 不久,您就會發現 SDL 只提供原始數據類型,而缺少高級標量數據類型,例如日期、時間和日期時間,這些都是每個 API 的重要組成部分。 幸運的是,我們有一個庫可以幫助我們解決這個問題,它叫做 graphql-iso-date。 安裝後,我們需要在模式中定義新的高級標量數據類型,並將它們連接到庫提供的實現:
import { GraphQLDate, GraphQLDateTime, GraphQLTime } from 'graphql-iso-date'; // Define APIs using GraphQL SDL const typeDefs = gql` scalar Date scalar Time scalar DateTime type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Date: GraphQLDate, Time: GraphQLTime, DateTime: GraphQLDateTime, Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } };
除了日期和時間之外,還存在其他有趣的標量數據類型實現,根據您的用例,它們可能對您有用。 例如,其中之一是 graphql-type-json,它使我們能夠在 GraphQL 模式中使用動態類型,並使用我們的 API 傳遞或返回無類型的 JSON 對象。 還有一個庫 graphql-scalar,它使我們能夠定義具有高級清理/驗證/轉換的自定義 GraphQL 標量。
如果需要,您還可以定義自定義標量數據類型並在架構中使用它,如上所示。 這並不難,但對它的討論超出了本文的範圍——如果有興趣,您可以在 Apollo 文檔中找到更高級的信息。
拆分模式
在向您的架構添加更多功能後,它將開始增長,我們將理解將整個定義集保存在一個文件中是不可能的,我們需要將其拆分為小塊以組織代碼並使其更具可擴展性更大的尺寸。 幸運的是,Apollo 提供的模式構建器函數makeExecutableSchema
也接受模式定義和數組形式的解析器映射。 這使我們能夠將模式和解析器映射拆分為更小的部分。 這正是我在示例項目中所做的; 我將 API 分為以下幾個部分:
-
auth.api.graphql
– 用於用戶認證和註冊的 API -
author.api.graphql
– 作者條目的 CRUD API -
book.api.graphql
- 書籍條目的 CRUD API -
root.api.graphql
- 模式和通用定義的根(如高級標量類型) -
user.api.graphql
- 用於用戶管理的 CRUD API
在拆分模式期間,我們必須考慮一件事。 其中一個部分必須是根模式,而其他部分必須擴展根模式。 這聽起來很複雜,但實際上很簡單。 在根模式中,查詢和突變定義如下:
type Query { ... } type Mutation { ... }
在其他的中,它們的定義如下:
extend type Query { ... } extend type Mutation { ... }
就這樣。
認證和授權
在大多數 API 實現中,都需要限制全局訪問並提供某種基於規則的訪問策略。 為此,我們必須在代碼中引入: Authentication - 確認用戶身份 - 和Authorization ,以強制執行基於規則的訪問策略。
在 GraphQL 世界中,與 REST 世界一樣,我們通常使用 JSON Web Token 進行身份驗證。 要驗證傳遞的 JWT 令牌,我們需要攔截所有傳入請求並檢查它們的授權標頭。 為此,在創建 Apollo 服務器期間,我們可以註冊一個函數作為上下文鉤子,該函數將與當前請求一起調用,該請求創建在所有解析器之間共享的上下文。 這可以這樣做:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, context: ({ req, res }) => { const context = {}; // Verify jwt token const parts = req.headers.authorization ? req.headers.authorization.split(' ') : ['']; const token = parts.length === 2 && parts[0].toLowerCase() === 'bearer' ? parts[1] : undefined; context.authUser = token ? verify(token) : undefined; return context; } }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });
在這裡,如果用戶將傳遞正確的 JWT 令牌,我們將對其進行驗證並將用戶對象存儲在上下文中,在請求執行期間所有解析器都可以訪問該對象。
我們驗證了用戶身份,但我們的 API 仍然可以全局訪問,並且沒有什麼可以阻止我們的用戶在未經授權的情況下調用它。 防止這種情況的一種方法是在每個解析器中直接檢查上下文中的用戶對象,但這是一種非常容易出錯的方法,因為我們必須編寫大量樣板代碼,並且在添加新解析器時我們可能忘記添加檢查. 如果我們看一下 REST API 框架,一般這類問題都是使用 HTTP 請求攔截器來解決的,但是對於 GraphQL,它沒有意義,因為一個 HTTP 請求可以包含多個 GraphQL 查詢,如果我們仍然添加它,我們只能訪問查詢的原始字符串表示,並且必須手動解析它,這絕對不是一個好方法。 這個概念不能很好地從 REST 轉換到 GraphQL。
所以我們需要某種方式來攔截 GraphQL 查詢,這種方式稱為 prisma-graphql-middleware。 這個庫允許我們在調用解析器之前或之後運行任意代碼。 它通過啟用代碼重用和清晰的關注點分離來改進我們的代碼結構。
GraphQL 社區已經基於 Prisma 中間件庫創建了一堆很棒的中間件,它解決了一些特定的用例,並且對於用戶授權,有一個名為 graphql-shield 的庫,它可以幫助我們為我們的 API 創建一個權限層。
安裝 graphql-shield 後,我們可以為我們的 API 引入一個權限層,如下所示:
import { allow } from 'graphql-shield'; const isAuthorized = rule()( (obj, args, { authUser }, info) => authUser && true ); export const permissions = { Query: { '*': isAuthorized, sayHello: allow }, Mutation: { '*': isAuthorized, sayHello: allow } }
我們可以將這一層作為中間件應用到我們的模式中,如下所示:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })
在創建屏蔽對象時,我們將allowExternalErrors
設置為 true,因為默認情況下,屏蔽的行為是捕獲和處理解析器內部發生的錯誤,這對於我的示例應用程序來說是不可接受的行為。

在上面的示例中,我們只限制了經過身份驗證的用戶對 API 的訪問,但是屏蔽非常靈活,使用它,我們可以為我們的用戶實現非常豐富的授權模式。 例如,在我們的示例應用程序中,我們有兩個角色: USER
和USER_MANAGER
,並且只有具有USER_MANAGER
角色的用戶才能調用用戶管理功能。 這是這樣實現的:
export const isUserManager = rule()( (obj, args, { authUser }, info) => authUser && authUser.role === 'USER_MANAGER' ); export const permissions = { Query: { userById: isUserManager, users: isUserManager }, Mutation: { editUser: isUserManager, deleteUser: isUserManager } }
我還要提到的一件事是如何在我們的項目中組織中間件功能。 與模式定義和解析器映射一樣,最好將它們按模式拆分並保存在單獨的文件中,但與 Apollo 服務器不同,它接受模式定義和解析器映射的數組並為我們縫合它們,Prisma 中間件庫不這樣做並且只接受一個中間件映射對象,所以如果我們拆分它們,我們必須手動將它們縫合回去。 要查看我對這個問題的解決方案,請參閱示例項目中的ApiExplorer
類。
驗證
GraphQL SDL 提供非常有限的功能來驗證用戶輸入; 我們只能定義哪個字段是必需的,哪個是可選的。 任何進一步的驗證要求,我們必須手動實施。 我們可以直接在解析器函數中應用驗證規則,但是這個功能真的不屬於這裡,這是用戶 GraphQL 中間件的另一個很好的用例。 例如,讓我們使用用戶註冊請求輸入數據,我們必須驗證用戶名是否是正確的電子郵件地址、密碼輸入是否匹配以及密碼是否足夠強大。 這可以像這樣實現:
import { UserInputError } from 'apollo-server-express'; import passwordValidator from 'password-validator'; import { isEmail } from 'validator'; const passwordSchema = new passwordValidator() .is().min(8) .is().max(20) .has().letters() .has().digits() .has().symbols() .has().not().spaces(); export const validators = { Mutation: { signup: (resolve, parent, args, context) => { const { email, password, rePassword } = args.signupReq; if (!isEmail(email)) { throw new UserInputError('Invalid Email address!'); } if (password !== rePassword) { throw new UserInputError('Passwords don\'t match!'); } if (!passwordSchema.validate(password)) { throw new UserInputError('Password is not strong enough!'); } return resolve(parent, args, context); } } }
我們可以將驗證器層作為中間件應用到我們的模式中,以及這樣的權限層:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, validators, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app })
N + 1 個查詢
另一個需要考慮的問題是 N + 1 個查詢,它發生在 GraphQL API 中並且經常被忽視。 當我們在模式中定義的類型之間存在一對多關係時,就會發生此問題。 為了演示它,例如,讓我們使用示例項目的 book API:
extend type Query { books: [Book!]! ... } extend type Mutation { ... } type Book { id: ID! creator: User! createdAt: DateTime! updatedAt: DateTime! authors: [Author!]! title: String! about: String language: String genre: String isbn13: String isbn10: String publisher: String publishDate: Date hardcover: Int } type User { id: ID! createdAt: DateTime! updatedAt: DateTime! fullName: String! email: String! }
在這裡,我們看到User
類型與Book
類型是一對多的關係,這種關係在Book
中表示為 creator 字段。 此模式的解析器映射定義如下:
export const resolvers = { Query: { books: (obj, args, context, info) => { return bookService.findAll(); }, ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { return userService.findById(creatorId); }, ... } }
如果我們使用此 API 執行書籍查詢,並查看 SQL 語句日誌,我們將看到如下內容:
select `books`.* from `books` select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? ...
很容易猜到——在執行過程中,首先為書籍查詢調用解析器,它返回書籍列表,然後每個書籍對像被稱為創建者字段解析器,這種行為導致了 N + 1 次數據庫查詢。 如果我們不想爆炸我們的數據庫,那麼這種行為並不是很好。
為了解決 N + 1 查詢問題,Facebook 開發人員創建了一個非常有趣的解決方案,稱為 DataLoader,它在其 README 頁面上是這樣描述的:
“DataLoader 是一個通用實用程序,可用作應用程序數據獲取層的一部分,通過批處理和緩存為各種遠程數據源(例如數據庫或 Web 服務)提供簡化且一致的 API”
理解 DataLoader 的工作原理並不是很簡單,所以讓我們先看看解決上面演示的問題的示例,然後解釋其背後的邏輯。
在我們的示例項目中,DataLoader 為 creator 字段定義如下:
export class UserDataLoader extends DataLoader { constructor() { const batchLoader = userIds => { return userService .findByIds(userIds) .then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) ); }; super(batchLoader); } static getInstance(context) { if (!context.userDataLoader) { context.userDataLoader = new UserDataLoader(); } return context.userDataLoader; } }
一旦我們定義了 UserDataLoader,我們可以像這樣改變 creator 字段的解析器:
export const resolvers = { Query: { ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { const userDataLoader = UserDataLoader.getInstance(context); return userDataLoader.load(creatorId); }, ... } }
應用更改後,如果我們再次執行 books 查詢並查看 SQL 語句日誌,我們將看到如下內容:
select `books`.* from `books` select `users`.* from `users` where `id` in (?)
在這裡,我們可以看到 N + 1 個數據庫查詢被減少為兩個查詢,其中第一個查詢選擇圖書列表,第二個查詢選擇圖書列表中作為創建者呈現的用戶列表。 現在讓我們解釋一下DataLoader是如何實現這個結果的。
DataLoader 的主要功能是批處理。 在單次執行階段,DataLoader 將收集所有單個加載函數調用的所有不同 id,然後使用所有請求的 id 調用批處理函數。 要記住的重要一點是 DataLoaders 的實例不能被重用,一旦調用批處理函數,返回的值將永遠緩存在實例中。 由於這種行為,我們必須在每個執行階段創建新的 DataLoader 實例。 為此,我們創建了一個靜態getInstance
函數,該函數檢查 DataLoader 的實例是否存在於上下文對像中,如果未找到,則創建一個。 請記住,為每個執行階段創建一個新的上下文對象,並在所有解析器之間共享。
DataLoader 的批量加載函數接受一組不同的請求 ID,並返回一個 Promise,該 Promise 解析為相應對象的數組。 在編寫批量加載函數時,我們必須記住兩件重要的事情:
- 結果數組的長度必須與請求的 ID 數組的長度相同。 例如,如果我們請求 ID
[1, 2, 3]
,返回的結果數組必須正好包含三個對象:[{ "id": 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }, { “id”: 3, “fullName”: “user3” }]
- 結果數組中的每個索引必須對應於請求的 ID 數組中的相同索引。 例如,如果請求的 ID 數組具有以下順序:
[3, 1, 2]
,則返回的結果數組必須包含完全相同順序的對象:[{ "id": 3, “fullName”: “user3” }, { “id”: 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }]
在我們的示例中,我們使用以下代碼確保結果的順序與請求的 ID 的順序相匹配:
then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) )
安全
最後但並非最不重要的一點是,我想提一下安全性。 使用 GraphQL,我們可以創建非常靈活的 API,並為用戶提供豐富的數據查詢功能。 這為應用程序的客戶端賦予了相當大的權力,正如本叔叔所說,“權力越大,責任越大。” 如果沒有適當的安全措施,惡意用戶可以提交昂貴的查詢並在我們的服務器上引發 DoS(拒絕服務)攻擊。
為了保護我們的 API,我們可以做的第一件事是禁用 GraphQL 模式的自省。 默認情況下,GraphQL API 服務器公開了自省其整個模式的能力,這通常被 GraphiQL 和 Apollo Playground 等交互式可視化 shell 使用,但對於惡意用戶基於我們的 API 構建複雜查詢也非常有用. 我們可以通過在創建 Apollo Server 時將introspection
參數設置為 false 來禁用此功能:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })
為了保護我們的 API,我們可以做的下一件事是限制查詢的深度。 如果我們的數據類型之間存在循環關係,這一點尤其重要。 例如,在我們的示例中,項目類型Author
具有字段書籍,類型Book
具有字段作者。 這顯然是一個循環關係,沒有什麼可以阻止惡意用戶編寫這樣的查詢:
query { authors { id, fullName books { id, title authors { id, fullName books { id, title, authors { id, fullName books { id, title authors { ... } } } } } } } }
很明顯,如果嵌套足夠多,這樣的查詢很容易炸毀我們的服務器。 為了限制查詢的深度,我們可以使用一個名為 graphql-depth-limit 的庫。 安裝完成後,我們可以在創建 Apollo Server 時應用深度限制,如下所示:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false, validationRules: [ depthLimit(5) ] }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })
在這裡,我們將查詢的最大深度限制為五個。
Post Scriptum:從 REST 遷移到 GraphQL 很有趣
在本教程中,我嘗試演示在開始實施 GraphQL API 時會遇到的常見問題。 然而,它的某些部分提供了非常淺薄的代碼示例,並且由於其大小而僅觸及所討論問題的表面。 因此,要查看更完整的代碼示例,請參閱我的示例 GraphQL API 項目的 Git 存儲庫:graphql-example。
最後,我想說 GraphQL 是一個非常有趣的技術。 它會取代 REST 嗎? 沒有人知道,也許明天在瞬息萬變的 IT 世界中,會出現一些更好的 API 開發方法,但 GraphQL 確實屬於絕對值得學習的有趣技術類別。