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

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

GraphQL 解決了什麼問題

過去,服務端的研發人員設計一個數據接口,一般並不會要求使用接口的客戶端研發人員知道接口內部的數據結構,而是隻提供一份 api 文檔(使用說明書),文檔內容介紹如何調用 API,返回什麼數據,文檔和功能都實現後,就算完成服務端工做了。前端

咱們使用這個工做方式工做,慢慢地發現了一些問題:java

  • 專門寫 API 文檔成了一種負擔
  • API 文檔和 API 服務常常部署在不一樣的域名,咱們須要記住文檔在哪
  • 咱們老是發現 API 的實際行爲和文檔不一致
  • API 內部數據的枚舉值,老是泄露到客戶端
  • API 參數校驗工做在客戶端和服務器重複進行
  • 咱們很難看到整個應用數據的結構圖
  • 咱們不得不維護多個 API 版本

慢慢地,咱們發現,在服務端和客戶端之間,須要共享一份數據描述規範:node

  • 這份數據描述就是功能的一部分(注意,它不是註釋),它參與實現 API 功能。
  • 這份數據描述自己就是文檔,咱們不在須要專門寫文檔,更不須要專門去部署文檔服務。
  • 當咱們修改了數據描述細節,API 功能就會發生變化,咱們無需擔憂文檔和行爲不一致的問題。
  • 數據描述自己支持枚舉類型,限制枚舉值泄露的問題。
  • 數據描述自己有類型系統,咱們無需在客戶端和服務器重複作參數校驗工做。
  • 數據描述自己就是整個應用數據的結構圖。
  • 數據描述能屏蔽版本維護的問題。

GraphQL 就是這麼一種數據描述規範。git

什麼是 GraphQL

官網的介紹以下:github

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

下面是一個基於 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)json

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,
  resolvers,
  introspection: !isProd,
  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,
  resolvers,
  introspection: !isProd,
  playground: !isProd,
  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
  }),
  introspection: !isProd,
  playground: !isProd,
  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, args, context, info) => ({
      name: '地球往事',
      price: 66.3,
      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, args, context, info) => ({
      name: '地球往事',
      price: 66.3,
      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(),
  formatError: error => ({
    code: error.extensions.code,
    message: error.message
  }),
  context: ({ ctx }) => ({ ctx }),
  introspection: !isProd,
  playground: !isProd,
  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(),
  formatError: error => ({
    code: error.extensions.code,
    message: error.message
  }),
  schemaDirectives: { ...allCustomDirectives },
  context: ({ ctx }) => ({ ctx }),
  introspection: !isProd,
  playground: !isProd,
  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, args, context, 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, args, context, info) => cats
  },
  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…


水滴前端團隊招募夥伴,歡迎投遞簡歷到郵箱:fed@shuidihuzhu.com

相關文章
相關標籤/搜索