1. 程式人生 > 程式設計 >【第十期】基於 Apollo、Koa 搭建 GraphQL 服務端

【第十期】基於 Apollo、Koa 搭建 GraphQL 服務端

本文預期讀者對 NodeJS、Koa 有一定的瞭解

GraphQL 解決了什麼問題

過去,服務端的研發人員設計一個資料介面,通常並不會要求使用介面的客戶端研發人員知道介面內部的資料結構,而是隻提供一份 api 檔案(使用說明書),檔案內容介紹如何呼叫 API,返回什麼資料,檔案和功能都實現後,就算完成服務端工作了。

我們使用這個工作方式工作,慢慢地發現了一些問題:

  • 專門寫 API 檔案成了一種負擔
  • API 檔案和 API 服務經常部署在不同的域名,我們需要記住檔案在哪
  • 我們總是發現 API 的實際行為和檔案不一致
  • API 內部資料的列舉值,總是洩露到客戶端
  • API 引數校驗工作在客戶端和伺服器重複進行
  • 我們很難看到整個應用資料的結構圖
  • 我們不得不維護多個 API 版本

慢慢地,我們發現,在服務端和客戶端之間,需要共享一份資料描述規範:

  • 這份資料描述就是功能的一部分(注意,它不是註釋),它參與實現 API 功能。
  • 這份資料描述本身就是檔案,我們不在需要專門寫檔案,更不需要專門去部署檔案服務。
  • 當我們修改了資料描述細節,API 功能就會發生變化,我們無需擔心檔案和行為不一致的問題。
  • 資料描述本身支援列舉型別,限制列舉值洩露的問題。
  • 資料描述本身有型別系統,我們無需在客戶端和伺服器重複做引數校驗工作。
  • 資料描述本身就是整個應用資料的結構圖。
  • 資料描述能遮蔽版本維護的問題。

GraphQL 就是這麼一種資料描述規範。

什麼是 GraphQL

官網的介紹如下:

GraphQL 是一個用於 API 的查詢語言,是一個使用基於型別系統來執行查詢的服務端執行時(型別系統由你的資料定義)。

下面是一個基於 GraphQL 的資料描述:

type Query {
  book: Book
}

enum BookStatus {
  DELETED
  NORMAL
}

type Book {
  id: ID
  name: String
  price: Float
  status: BookStatus
}
複製程式碼

為了不和特定的平臺繫結,且更容易理解服務端的功能,GraphQL 實現了一個易讀的 schema 語法: Schema Definition Language

(SDL)

SDL 用於表示 schema 中可用的型別,以及這些型別之間的關係

SDL 必須以字串的形式儲存

SDL 規定了三類入口,分別為:

  • Query 用於定義操作 (可以理解為 CURD 中的 R)
  • Mutation 用於定義操作 (可以理解為 CURD 中的 CUD)
  • Subscription 用於定義長連結(基於事件的、建立和維持與服務端實時連線的方式)

通過上述程式碼,我們宣告瞭一個查詢,名為 book,型別為 Book

型別 Book 擁有四個欄位,分別為:

  • id,代表每本書的唯一id,型別為 ID
  • name,代表每本書的名字,型別為字串
  • price,代表每本書的價格,型別為浮點數
  • status,代表每本書的狀態,型別為 BookStatus

BookStatus 是一個列舉型別,包含:

  • DELETED 代表書已經下架,它的值為 0
  • NORMAL 代表書在正常銷售中,它的值為 1

除了可以定義自己的資料型別外,GraphQL 還內建了一下幾種基礎型別(標量):

  • Int: 有符號 32 位整型
  • Float: 有符號雙精度浮點型
  • String: UTF-8 字元序列
  • Boolean: true 或 false
  • ID: 一個唯一的標識,經常用於重新獲取一個物件或作為快取的 key

這裡需要注意,GraphQL 要求端點欄位的型別必須是標量型別。(這裡的端點可以理解為葉子節點)

關於 GraphQL 的更多資訊,請參考:graphql.cn/learn/

什麼是 Apollo

官網的介紹如下:

Apollo 是 GraphQL 的一個實現,可幫助您管理從雲到 UI 的資料。它可以逐步採用,並在現有服務上進行分層,包括 REST API 和資料庫。Apollo 包括兩組用於客戶端和伺服器的開源庫,以及開發人員工具,它提供了在生產中可靠地執行 GraphQL API 所需的一切。

我們可以將 Apollo 看作是一組工具,它分為兩大類,一類面向服務端,另一類面向客戶端。

其中,面向客戶端的 Apollo Client 涵蓋了以下工具和平臺:

  • React + React Native
  • Angular
  • Vue
  • Meteor
  • Ember
  • IOS (Swift)
  • Android (Java)
  • ...

面向服務端的 Apollo Server 涵蓋了以下平臺:

  • Java
  • Scala
  • Ruby
  • Elixir
  • NodeJS
  • ...

我們在本文中會使用 Apollo 中針對 NodeJS 服務端 koa 框架的 apollo-server-koa

關於 apollo server 和 apollo-server-koa 的更多資訊請參考:

搭建 GraphQL 後端 api 服務

快速搭建

step1:

新建一個資料夾,我這裡新建了 graphql-server-demo 資料夾

mkdir graphql-server-demo
複製程式碼

在資料夾內初始化專案:

cd graphql-server-demo && yarn init
複製程式碼

安裝依賴:

yarn add koa graphql apollo-server-koa
複製程式碼
step2:

新建 index.js 檔案,並在其中撰寫如下程式碼:

'use strict'

const path = require('path')
const Koa = require('koa')
const app = new Koa()
const { ApolloServer,gql } = require('apollo-server-koa')

/**
 * 在 typeDefs 裡定義 GraphQL Schema
 *
 * 例如:我們定義了一個查詢,名為 book,型別是 Book
 */
const typeDefs = gql`
  type Query {
    book: Book
    hello: String
  }

  enum BookStatus {
    DELETED
    NORMAL
  }

  type Book {
    id: ID
    name: String
    price: Float
    status: BookStatus
  }
`;

const BookStatus = {
  DELETED: 0,NORMAL: 1
}
/**
 * 在這裡定義對應的解析器
 * 
 * 例如:
 *   針對查詢 hello,定義同名的解析器函式,返回字串 "hello world!"
 *   針對查詢 book,定義同名的解析器函式,返回預先定義好的物件(實際場景可能返回來自資料庫或其他介面的資料)
 */
const resolvers = {

  // Apollo Server 允許我們將實際的列舉對映掛載到 resolvers 中(這些對映關係通常維護在服務端的配置檔案或資料庫中)
  // 任何對於此列舉的資料交換,都會自動將列舉值替換為列舉名,避免了列舉值洩露到客戶端的問題
  BookStatus,Query: {

    hello: () => 'hello world!',book: (parent,args,context,info) => ({
      name:'地球往事',price: 66.3,status: BookStatus.NORMAL
    })

  }
};

// 通過 schema、解析器、 Apollo Server 的建構函式,建立一個 server 例項
const server = new ApolloServer({ typeDefs,resolvers })
// 將 server 例項以中介軟體的形式掛載到 app 上
server.applyMiddleware({ app })
// 啟動 web 服務
app.listen({ port: 4000 },() =>
  console.log(`? Server ready at http://localhost:4000/graphql`)
)
複製程式碼

通過觀察上述程式碼,我們發現: SDL 中定義的查詢 book,有一個同名的解析器 book 作為其資料來源的實現。

事實上,GraphQL 要求每個欄位都需要有對應的 resolver,對於端點欄位,也就是那些標量型別的欄位,大部分 GraphQL 實現庫允許省略這些欄位的解析器定義,這種情況下,會自動從上層物件(parent)中讀取與此欄位同名的屬性。

因為上述程式碼中,hello 是一個根欄位,它沒有上層物件,所以我們需要主動為它實現解析器,指定資料來源。

解析器是一個函式,這個函式的形參名單如下:

  • parent 上一級物件,如當前為根欄位,則此引數值為 undefined
  • argsSDL 查詢中傳入的引數
  • context 此引數會被提供給所有解析器,並且持有重要的上下文資訊比如當前登入的使用者或者資料庫訪問物件
  • info 一個儲存與當前查詢相關的欄位特定資訊以及 schema 詳細資訊的值
step3:

啟動服務

node index.js
複製程式碼

此時,我們在終端看到如下資訊:

➜  graphql-server-demo git:(master) ✗ node index.js
? Server ready at http://localhost:4000/graphql
複製程式碼

代表服務已經啟動了

開啟另一個終端介面,請求我們剛剛啟動的 web 服務:

curl 'http://localhost:4000/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"{hello}"}'
複製程式碼

或者

curl 'http://localhost:4000/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"{book{name price status}}"}'
複製程式碼

看到如下資訊:

{"data":{"hello":"Hello world!"}}
複製程式碼

或者

{"data":{"book":{"name":"地球往事","price":66.3,"status":"NORMAL"}}}
複製程式碼

代表我們已經成功建立了 GraphQL API 服務~

在終端使用命令來除錯 GraphQL API,這顯然不是我們大部分人想要的。

我們需要一個帶有記憶功能的圖形介面客戶端,來幫助我們記住上一次每個查詢的引數。

除了通過這個客戶端自定義查詢引數外,還能自定義頭部欄位、檢視 Schema 檔案、檢視整個應用的資料結構...

接下來,我們來看 Apollo 為我們提供的 palyground

Playground

啟動我們剛才建立的 GraphQL 後端服務:

➜  graphql-server-demo git:(master) ✗ node index.js
? Server ready at http://localhost:4000/graphql
複製程式碼

我們在瀏覽器中開啟地址 http://localhost:4000/graphql

這時,我們會看到如下介面:

在左側輸入查詢引數:

{
  book {
    name
    price
  }
}
複製程式碼

然後點選中間那個按鈕來發出請求(話說這個按鈕設計得很像播放按鈕,以至於我第一次看到它,以為這是一個視訊...),請求成功後,我們會看到右側輸出了結果:

playground 還為我們提供瞭如下部分功能:

  • 建立多個查詢,並記住它們
  • 自定義請求頭部欄位
  • 檢視整個 api 檔案
  • 檢視完整的服務端 Schema 結構

如下圖:

其中,DOCSSCHEMA 中的內容是通過 GraphQL 的一個叫做 內省introspection)的功能提供的。

內省 功能允許我們通過客戶端查詢引數詢問 GraphQL Schema 支援哪些查詢,而 playground 在啟動的時候,就會預先傳送 內省 請求,獲取 Schema 資訊,並組織好 DOCSSCHEMA 的內容結構。

關於 內省 更詳細的內容請參考: graphql.cn/learn/intro…

對於 playground 和 內省,我們希望只在開發和測試生產環境才開啟它們,生產環境中我們希望它們是關閉的。

我們可以在建立 Apollo Server 例項的時候,通過對應的開關(playgroundintrospection),來遮蔽生產環境:

...
const isProd = process.env.NODE_ENV === 'production'

const server = new ApolloServer({
  typeDefs,resolvers,introspection: !isProd,playground: !isProd
})
...
複製程式碼

接下來,我們來考慮一個比較常見的問題:

客戶端和服務端將 api 檔案共享之後,往往服務端的功能需要一些時間來研發,在功能研發完畢之前,客戶端實際上是無法從 api 請求到真實資料的,這個時候,為了方便客戶端的研發工作,我們會讓 api 返回一些假資料。

接下來,我們看看在 GraphQL 服務端,怎麼做這件事。

Mock

使用基於 Apollo ServerGraphQL 服務端來實現 api 的 mock 功能,是非常簡單的。

我們只需要在構建 Apollo Server 例項時,開啟 mocks 選項即可:

...
const server = new ApolloServer({
  typeDefs,playground: !isProd,mocks: true
})
...
複製程式碼

重新啟動服務,並在 playground 中發出請求,會看到請求結果的資料變成了型別內的隨機假資料:

得益於 GraphQL 的型別系統,雖然我們通過 mock 提供了隨機資料,但這些資料的型別和 Schema 中定義的型別是一致的,這無疑減輕了我們配置 mock 的工作量,讓我們可以把精力節省下來,聚焦到型別上。

實際上,我們對於型別定義得越是精準,我們的 mock 服務的質量就越高。

引數校驗與錯誤資訊

上一節我們看到了型別系統對於 mock 服務給予的一些幫助。

對於型別系統來說,它能發揮的另一個場景是:請求引數校驗

通過型別系統,GraphQL 能很容易得預先判斷一個查詢是否符合 Schema 的規格,而不必等到後面執行的時候才發現請求引數的問題。

例如我們查詢一個 book 中不存在的欄位,會被 GraphQL 攔截,並返回錯誤:

我們看到請求返回結果中不再包含 data 欄位,而只有一個 error 欄位,在其中的 errors 陣列欄位裡展示了每一個錯誤的具體錯誤細節。

實際上,當我們在 playground 中輸入 none 這個錯誤的欄位名稱時,playgorund 就已經發現了這個錯誤的引數,並給出了提示,注意到上圖左側那個紅色小塊了麼,將滑鼠懸停在錯誤的欄位上時,playground 會給出具體的錯誤提示,和服務端返回的錯誤內容是一致的:

這種錯誤在我們寫查詢引數的時候,就能被發現,不必發出請求,真是太棒了,不是麼。

另外我們發現服務端返回的錯誤結果,實際上並不是那麼易讀,對於產品環境來說,詳細的錯誤資訊,我們只想列印到服務端的日誌中,並不像返回給客戶端。

因此對於響應給客戶端的資訊,我們可能只需要返回一個錯誤型別和一個簡短的錯誤描述:

{
  "error": {
    "errors":[
      {
        "code":"GRAPHQL_VALIDATION_FAILED","message":"Cannot query field \"none\" on type \"Book\". Did you mean \"name\"?"
      }
    ]
  }
}
複製程式碼

我們可以在構建 Apollo Server 例項時,傳遞一個名為 formatError 的函式來格式化返回的錯誤資訊:

...
const server = new ApolloServer({
  typeDefs,mocks: true,formatError: error => {
    // log detail of error here
    return {
      code: error.extensions.code,message: error.message
    }
  }
})
...
複製程式碼

重啟服務,再次請求,我們發現錯誤資訊被格式化為我們預期的格式:

組織 Schema 與 Resolver

目前為止,我們搭建的 GraphQL 服務端還非常的簡陋:

├── index.js
├── package.json
└── yarn.lock
複製程式碼

它還無法應用到實際工程中,因為它太 自由 了,我們需要為它設計一些 規矩,來幫助我們更好得應對實際工程問題。

到這一節,相信讀者已經感覺到 GraphQL 帶來了哪些心智模型的改變:

  • 我們原來組織 路由 的工作,部分變成了現在的組織 Schema 的工作
  • 我們原來組織 控制器 的工作,部分變成了現在的組織 Resolver 的工作

我們來設計一個 規矩,幫助我們組織好 SchemaResolver:

  • 新建資料夾 src,用於存放絕大部分工程程式碼
  • src 中新建資料夾 components ,用於存放資料實體
  • 每個資料實體是一個資料夾,包含兩個檔案:schema.jsresolver.js,他們分別儲存關於當前資料實體的 SchemaResolver 的描述
  • src/components 中新建資料夾 book,並在其中新建 schema.jsresolver.js 用於存放 book 相關的描述
  • src 建立資料夾 graphql,存放所有 GraphQL 相關邏輯
  • graphql 中新建檔案 index.js,作為 GraphQL 啟動檔案,負責在服務端應用啟動時,收集所有資料實體,生成 Apollo Server 例項

按照上述步驟調整完畢後,graphql-server-demo 的整個結構如下:

├── index.js
├── package.json
├── src
│   ├── components
│   │   └── book
│   │       ├── resolver.js
│   │       └── schema.js
│   └── graphql
│       └── index.js
└── yarn.lock
複製程式碼

接下來我們調整程式碼

Step 1

先來看 GraphQL 入口檔案 src/graphql/index.js 的職責:

  • 負責讀取合併所有 components 的 SchemaResolver
  • 負責建立 Apollo Server 例項

入口檔案 src/graphql/index.js 的最終程式碼如下:

const fs = require('fs')
const { resolve } = require('path')
const { ApolloServer,gql } = require('apollo-server-koa')

const defaultPath = resolve(__dirname,'../components/')
const typeDefFileName = 'schema.js'
const resolverFileName = 'resolver.js'

/**
 * In this file,both schemas are merged with the help of a utility called linkSchema.
 * The linkSchema defines all types shared within the schemas.
 * It already defines a Subscription type for GraphQL subscriptions,which may be implemented later.
 * As a workaround,there is an empty underscore field with a Boolean type in the merging utility schema,because there is no official way of completing this action yet.
 * The utility schema defines the shared base types,extended with the extend statement in the other domain-specific schemas.
 *
 * Reference: https://www.robinwieruch.de/graphql-apollo-server-tutorial/#apollo-server-resolvers
 */
const linkSchema = gql`

  type Query {
    _: Boolean
  }

  type Mutation {
    _: Boolean
  }

  type Subscription {
    _: Boolean
  }
`

function generateTypeDefsAndResolvers () {
  const typeDefs = [linkSchema]
  const resolvers = {}

  const _generateAllComponentRecursive = (path = defaultPath) => {
    const list = fs.readdirSync(path)

    list.forEach(item => {
      const resolverPath = path + '/' + item
      const stat = fs.statSync(resolverPath)
      const isDir = stat.isDirectory()
      const isFile = stat.isFile()

      if (isDir) {
        _generateAllComponentRecursive(resolverPath)
      } else if (isFile && item === typeDefFileName) {
        const { schema } = require(resolverPath)

        typeDefs.push(schema)
      } else if (isFile && item === resolverFileName) {
        const resolversPerFile = require(resolverPath)

        Object.keys(resolversPerFile).forEach(k => {
          if (!resolvers[k]) resolvers[k] = {}
          resolvers[k] = { ...resolvers[k],...resolversPerFile[k] }
        })
      }
    })
  }

  _generateAllComponentRecursive()

  return { typeDefs,resolvers }
}

const isProd = process.env.NODE_ENV === 'production'

const apolloServerOptions = {
  ...generateTypeDefsAndResolvers(),formatError: error => ({
    code: error.extensions.code,message: error.message
  }),mocks: false
}

module.exports = new ApolloServer({ ...apolloServerOptions })
複製程式碼

上述程式碼中,我們看到 linkSchema 的值中分別在 QueryMutationSubscription 三個型別入口中定義了一個名為 _,型別為 Boolean 的欄位。

這個欄位實際上是一個佔位符,因為官方尚未支援多個擴充套件(extend)型別合併的方法,因此這裡我們可以先設定一個佔位符,以支援合併擴充套件(extend)型別。

Step 2

我們來定義資料實體:bookSchemaResolver 的內容:

// src/components/book/schema.js
const { gql } = require('apollo-server-koa')

const schema = gql`

  enum BookStatus {
    DELETED
    NORMAL
  }

  type Book {
    id: ID
    name: String
    price: Float
    status: BookStatus
  }

  extend type Query {
    book: Book
  }
`

module.exports = { schema }
複製程式碼

這裡我們不在需要 hello 這個查詢,所以我們在調整 book 相關程式碼時,移除了 hello

通過上述程式碼,我們看到,通過 extend 關鍵字,我們可以單獨定義針對 book 的查詢型別

// src/components/book/resolver.js
const BookStatus = {
  DELETED: 0,NORMAL: 1
}

const resolvers = {

  BookStatus,Query: {

    book: (parent,info) => ({
      name: '地球往事',status: BookStatus.NORMAL
    })

  }
}

module.exports = resolvers
複製程式碼

上述程式碼定義了 book 查詢的資料來源,resolver 函式支援返回 Promise

Step 3

最後,我們來調整服務應用啟動檔案的內容:

const Koa = require('koa')
const app = new Koa()
const apolloServer = require('./src/graphql/index.js')

apolloServer.applyMiddleware({ app })

app.listen({ port: 4000 },() =>
  console.log(`? Server ready at http://localhost:4000/graphql`)
)
複製程式碼

wow~,服務啟動檔案的內容看起來精簡了很多。

在前面章節中我們說過:對於欄位型別,我們定義得越是精準,我們的 mock 服務和引數校驗服務的質量就越好。

那麼,現有的這幾個標量型別不滿足我們的需求時,怎麼辦呢?

接下來,我們來看如何實現自定義標量

自定義標量實現日期欄位

我們為 Book 新增一個欄位,名為 created,型別為 Date

...
  type Book {
    id: ID
    name: String
    price: Float
    status: BookStatus
    created: Date
  }
...
複製程式碼
    book: (parent,status: BookStatus.NORMAL,created: 1199116800000
    })
複製程式碼

GraphQL 標準中並沒有 Date 型別,我們來實現自定義的 Date 型別:

Step 1

首先,我們安裝一個第三方日期工具 moment:

yarn add moment
複製程式碼
Step 2

接下來,在 src/graphql 中新建資料夾 scalars

mkdir src/graphql/scalars
複製程式碼

我們在 scalars 這個資料夾中存放自定義標量

scalars 中新建檔案: index.jsdate.js

src/graphql/
├── index.js
└── scalars
    ├── date.js
    └── index.js
複製程式碼

檔案 scalars/index.js 負責匯出自定義標量 Date

module.exports = {
  ...require('./date.js')
}
複製程式碼

檔案 scalars/date.js 負責實現自定義標量 Date

const moment = require('moment')
const { Kind } = require('graphql/language')
const { GraphQLScalarType } = require('graphql')

const customScalarDate = new GraphQLScalarType({
  name: 'Date',description: 'Date custom scalar type',parseValue: value => moment(value).valueOf(),serialize: value => moment(value).format('YYYY-MM-DD HH:mm:ss:SSS'),parseLiteral: ast => (ast.kind === Kind.INT)
    ? parseInt(ast.value,10)
    : null
})

module.exports = { Date: customScalarDate }
複製程式碼

通過上述程式碼,我們看到,實現一個自定義標量,只需要建立一個 GraphQLScalarType 的例項即可。

在建立 GraphQLScalarType 例項時,我們可以指定:

  1. 自定義標量的名稱,也就是 name
  2. 自定義標量的簡介,也就是 description
  3. 當自定義標量值從客戶端傳遞到服務端時的處理函式,也就是 parseValue
  4. 當自定義標量值從服務端返回到客戶端時的處理函式,也就是 serialize
  5. 對於自定義標量在 ast 中的字面量的處理函式,也就是 parseLiteral(這是因為在 ast 中的值總是格式化為字串)

ast 即抽象語法樹,關於抽象語法樹的細節請參考:zh.wikipedia.org/wiki/抽象語法樹

Step 3

最後,讓我們將自定義標量 Date 掛載到 GraphQL 啟動檔案中:

...

const allCustomScalars = require('./scalars/index.js')

...

const linkSchema = gql`

  scalar Date

  type Query {
    _: Boolean
  }

  type Mutation {
    _: Boolean
  }

  type Subscription {
    _: Boolean
  }
`
...

function generateTypeDefsAndResolvers () {
  const typeDefs = [linkSchema]
  const resolvers = { ...allCustomScalars }
...

複製程式碼

最後,我們驗證一下,重啟服務,並請求 bookcreated 欄位,我們發現服務端已經支援 Date 型別了:

自定義指令實現登入校驗功能

本小節,我們來學習如何在 GraphQL 服務端實現登入校驗功能

過去,我們每個具體的路由,對應一個具體的資源,我們很容易為一部分資源新增保護(要求登入使用者才有訪問許可權),我們只需要設計一箇中介軟體,並在每一個需要保護的路由上新增一個標記即可。

GraphQL 打破了路由與資源對應的概念,它主張在 Schema 內部標記哪些欄位是受保護的,以此來提供資源保護的功能。

我們想要在 GraphQL 服務中實現登入校驗的功能,就需要藉助於以下幾個工具:

  • koa 中介軟體
  • resolver 中的 context
  • 自定義指令
Step 1

首先,我們定義一個 koa 中介軟體,在中介軟體中檢查請求頭部是否有傳遞使用者簽名,如果有,就根據此簽名獲取使用者資訊,並將使用者資訊掛載到 koa 請求上下文物件 ctx 上。

src 中新建資料夾 middlewares,用來存放所有 koa 的中介軟體

mkdir src/middlewares
複製程式碼

在資料夾 src/middlewares 中新建檔案 auth.js,作為掛載使用者資訊的中介軟體:

touch src/middlewares/auth.js
複製程式碼
async function main (ctx,next) {

  // 注意,在真實場景中,需要在這裡獲取請求頭部的使用者簽名,比如:token
  // 並根據使用者 token 獲取使用者資訊,然後將使用者資訊掛載到 ctx 上
  // 這裡為了簡單演示,省去了上述步驟,掛載了一個模擬的使用者資訊
  ctx.user = { name: 'your name',age: Math.random() }

  return next()
}

module.exports = main
複製程式碼

將此中介軟體掛載到應用上:

...

app.use(require('./src/middlewares/auth.js'))

apolloServer.applyMiddleware({ app })


app.listen({ port: 4000 },() =>
  console.log(`? Server ready at http://localhost:4000/graphql`)
)
複製程式碼

這裡需要注意一個細節,auth 中介軟體的掛載,必須要在 apolloServer 掛載的前面,這是因為 koa 中的請求,是按照掛載順序通過中介軟體棧的,我們預期在 apolloServer 處理請求前,在 ctx 上就已經掛載了使用者資訊

Step 2

通過解析器的 context 引數,傳遞 ctx 物件,方便後續通過該物件獲取使用者資訊(在前面小節中,我們介紹過解析器的形參名單,其中第三個引數名為 context

在建立 Apollo Server例項時,我們還可以指定一個名為 context 的選項,值可以是一個函式

context 的值為函式時,應用的請求上下文物件 ctx 會作為此函式第一個形參的一個屬性,傳遞給當前 context 函式;而 context 函式的返回值,會作為 context 引數傳遞給每一個解析器函式

因此我們只需要這麼寫,就可以將請求的上下文物件 ctx,傳遞給每個解析器:

...
const apolloServerOptions = {
  ...generateTypeDefsAndResolvers(),context: ({ ctx }) => ({ ctx }),mocks: false
}
...
複製程式碼

這樣,每個解析器函式,只需要簡單獲取第三個形參就能拿到 ctx 了,從而可以通過 ctx 獲取其上的 user 屬性(使用者資訊)

Step 3

然後,我們設計一個自定義指令 auth(它是 authentication的簡寫)

src/graphql 中新建資料夾 directives,用來存放所有自定義指令:

mkdir src/graphql/directives
複製程式碼

我們在 directives 這個資料夾中存放自定義指令

directives 中新建檔案: index.jsauth.js

src/graphql
├── directives
│   ├── auth.js
│   └── index.js
├── index.js
└── scalars
    ├── date.js
    └── index.js
複製程式碼

檔案 directives/index.js 負責匯出自定義指令 auth

module.exports = {
  ...require('./auth.js')
}
複製程式碼

檔案 directives/auth.js 負責實現自定義指令 auth

const { SchemaDirectiveVisitor,AuthenticationError } = require('apollo-server-koa')
const { defaultFieldResolver } = require('graphql')

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition (field) {
    const { resolve = defaultFieldResolver } = field

    field.resolve = async function (...args) {
      const context = args[2]
      const user = context.ctx.user

      console.log('[CURRENT USER]',{ user })

      if (!user) throw new AuthenticationError('Authentication Failure')

      return resolve.apply(this,args)
    }
  }
}

module.exports = {
  auth: AuthDirective
}
複製程式碼

通過上述程式碼,我們看到 Apollo Server 除了提供基礎的指令訪問者類 SchemaDirectiveVisitor 外,還提供了認證錯誤類 AuthenticationError

我們宣告一個自定義的 AuthDirective 類,繼承 SchemaDirectiveVisitor,並在其類方法 visitFieldDefinition 中撰寫針對每個捕獲到的欄位上需要執行的認證邏輯

認證邏輯非常簡單,在欄位原來的解析器基礎之上,包裝一層認證邏輯即可:

  1. 我們嘗試從 field 中獲取它的解析器,並將它臨時儲存在區域性變數中,方便接下來使用它。如果獲取不到,則賦值預設的解析器 defaultFieldResolver
  2. 我們覆蓋 field 上的解析器屬性為我們自定義的函式,在函式內我們通過 args[2] 訪問到了解析器函式的第三個形參,並從中獲取到掛載在 ctx 上的使用者資訊
  3. 如果使用者資訊不存在,則丟擲 AuthenticationError 錯誤
  4. 返回欄位原來的解析器執行結果
Step 4

在建立 Apollo Server 例項時通過 schemaDirectives 選項掛載自定義指令:

...

const allCustomDirectives = require('./directives/index.js')

...

const apolloServerOptions = {
  ...generateTypeDefsAndResolvers(),schemaDirectives: { ...allCustomDirectives },mocks: false
}

...
複製程式碼

在全域性 linkSchema 中宣告該指令,並在資料實體的 Schema 中為每個需要保護的欄位,標記上 @auth(代表需要登入才能訪問此欄位)

...
const linkSchema = gql`

  scalar Date

  directive @auth on FIELD_DEFINITION

  type Query {
    _: Boolean
  }

  type Mutation {
    _: Boolean
  }

  type Subscription {
    _: Boolean
  }
`
...
複製程式碼

上述程式碼中,FIELD_DEFINITION 代表此命令只作用於具體某個欄位

這裡,我們為僅有的 book 查詢欄位新增上我們的自定義指令 @auth

...
const schema = gql`

  enum BookStatus {
    DELETED
    NORMAL
  }

  type Book {
    id: ID
    name: String
    price: Float
    status: BookStatus
    created: Date
  }

  extend type Query {
    book: Book @auth
  }
`
...
複製程式碼

我們為 book 查詢欄位添加了 @auth 約束

接下來,我們重啟服務,請求 book,我們發現終端打印出:

[CURRENT USER] { user: { name: 'your name',age: 0.30990570160950015 } }
複製程式碼

這代表自定義指令的程式碼運行了

接下來我們註釋掉 auth 中介軟體中的模擬使用者程式碼:

async function main (ctx,next) {
  // 注意,在真實場景中,需要在這裡獲取請求頭部的使用者簽名,比如:token
  // 並根據使用者 token 獲取使用者資訊,然後將使用者資訊掛載到 ctx 上
  // 這裡為了簡單演示,省去了上述步驟,掛載了一個模擬的使用者資訊
  // ctx.user = { name: 'your name',age: Math.random() }

  return next()
}

module.exports = main
複製程式碼

重啟服務,再次請求 book,我們看到:

結果中出現了 errors,其 code 值為 UNAUTHENTICATED,這說明我們的指令成功攔截了未登入請求

合併請求

最後,我們來看一個由 GraphQL 的設計所導致的一個問題: 不必要的請求

我們在 graphql-server-demo 中增加一個新的資料實體: cat

最終目錄結構如下:

src
├── components
│   ├── book
│   │   ├── resolver.js
│   │   └── schema.js
│   └── cat
│       ├── resolver.js
│       └── schema.js
├── graphql
│   ├── directives
│   │   ├── auth.js
│   │   └── index.js
│   ├── index.js
│   └── scalars
│       ├── date.js
│       └── index.js
└── middlewares
    └── auth.js
複製程式碼

其中 src/components/cat/schema.js 的程式碼如下:

const { gql } = require('apollo-server-koa')

const schema = gql`

  type Food {
    id: Int
    name: String
  }

  type Cat {
    color: String
    love: Food
  }

  extend type Query {
    cats: [Cat]
  }
`

module.exports = { schema }
複製程式碼

我們定義了兩個資料型別: CatFood

並定義了一個查詢: cats, 此查詢返回一組貓

src/components/cat/resolver.js 的程式碼如下:

const foods = [
  { id: 1,name: 'milk' },{ id: 2,name: 'apple' },{ id: 3,name: 'fish' }
]

const cats = [
  { color: 'white',foodId: 1 },{ color: 'red',foodId: 2 },{ color: 'black',foodId: 3 }
]

const fakerIO = arg => new Promise((resolve,reject) => {
  setTimeout(() => resolve(arg),300)
})

const getFoodById = async id => {
  console.log('--- enter getFoodById ---',{ id })
  return fakerIO(foods.find(food => food.id === id))
}

const resolvers = {
  Query: {
    cats: (parent,info) => cats
  },Cat: {
    love: async cat => getFoodById(cat.foodId)
  }
}

module.exports = resolvers
複製程式碼

根據上述程式碼,我們看到:

  • 每隻貓都有一個 foodId 欄位,值為最愛吃的食物的 id
  • 我們通過函式 fakerIO 來模擬非同步IO
  • 我們實現了一個函式 getFoodById 提供根據食物 id 獲取食物資訊的功能,每呼叫一次 getFoodById 函式,都將列印一條日誌到終端

重啟服務,請求 cats,我們看到正常返回了結果:

我們去看一下終端的輸出,發現:

--- enter getFoodById --- { id: 1 }
--- enter getFoodById --- { id: 2 }
--- enter getFoodById --- { id: 3 }
複製程式碼

getFoodById 函式被分別呼叫了三次。

GraphQL 的設計主張為每個欄位指定解析器,這導致了:

一個批量的請求,在關聯其它資料實體時,每個端點都會造成一次 IO。

這就是 不必要的請求,因為上面這些請求可以合併為一次請求。

我們怎麼合併這些 不必要的請求 呢?

我們可以通過一個叫做 dataLoader 的工具來合併這些請求。

dataLoader 提供了兩個主要的功能:

  • Batching
  • Caching

本文中,我們只使用它的 Batching 功能

關於 dataLoader 更多的資訊,請參考: github.com/graphql/dat…

Step 1

首先,我們安裝 dataLoader

yarn add dataloader
複製程式碼
Step 2

接下來,我們在 src/components/cat/resolver.js 中:

  • 提供一個批量獲取 food 的函式 getFoodByIds
  • 引入 dataLoader,包裝 getFoodByIds 函式,返回一個包裝後的函式 getFoodByIdBatching
  • love 的解析器函式中使用 getFoodByIdBatching 來獲取 food
const DataLoader = require('dataloader')

...

const getFoodByIds = async ids => {
  console.log('--- enter getFoodByIds ---',{ ids })
  return fakerIO(foods.filter(food => ids.includes(food.id)))
}

const foodLoader = new DataLoader(ids => getFoodByIds(ids))

const getFoodByIdBatching = foodId => foodLoader.load(foodId)

const resolvers = {
  Query: {
    cats: (parent,Cat: {
    love: async cat => getFoodByIdBatching(cat.foodId)
  }
}

...
複製程式碼

重啟服務,再次請求 cats,我們依然看到返回了正確的結果,此時,我們去看終端,發現:

--- enter getFoodByIds --- { ids: [ 1,2,3 ] }
複製程式碼

原來的三次 IO 請求已經成功合併為一個了。

最終,我們的 graphql-server-demo 目錄結構如下:

├── index.js
├── package.json
├── src
│   ├── components
│   │   ├── book
│   │   │   ├── resolver.js
│   │   │   └── schema.js
│   │   └── cat
│   │       ├── resolver.js
│   │       └── schema.js
│   ├── graphql
│   │   ├── directives
│   │   │   ├── auth.js
│   │   │   └── index.js
│   │   ├── index.js
│   │   └── scalars
│   │       ├── date.js
│   │       └── index.js
│   └── middlewares
│       └── auth.js
└── yarn.lock
複製程式碼

結束語

讀到這裡,相信您對於構建 GraphQL 服務端,有了一個大致的印象。

這篇文章實際上只介紹了 GraphQL 中相當有限的一部分知識。想要全面且深入地掌握 GraphQL,還需要讀者繼續探索和學習。

至此,本篇文章就結束了,希望這篇文章能在接下來的工作和生活中幫助到您。

參考

關於 Apollo Server 構造器選項的完整名單請參考:www.apollographql.com/docs/apollo…


水滴前端團隊招募夥伴,歡迎投遞簡歷到郵箱:[email protected]