本文預期讀者對 NodeJS、Koa 有必定的瞭解javascript
過去,服務端的研發人員設計一個數據接口,一般並不會要求使用接口的客戶端研發人員知道接口內部的數據結構,而是隻提供一份 api 文檔(使用說明書),文檔內容介紹如何調用 API,返回什麼數據,文檔和功能都實現後,就算完成服務端工做了。前端
咱們使用這個工做方式工做,慢慢地發現了一些問題:java
慢慢地,咱們發現,在服務端和客戶端之間,須要共享一份數據描述規範:node
GraphQL 就是這麼一種數據描述規範。git
官網的介紹以下: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
擁有四個字段,分別爲:
BookStatus
BookStatus
是一個枚舉類型,包含:
DELETED
表明書已經下架,它的值爲 0
NORMAL
表明書在正常銷售中, 它的值爲 1
除了能夠定義本身的數據類型外,GraphQL 還內置了一下幾種基礎類型(標量):
Int
: 有符號 32 位整型Float
: 有符號雙精度浮點型String
: UTF-8 字符序列Boolean
: true 或 falseID
: 一個惟一的標識,常常用於從新獲取一個對象或做爲緩存的 key這裏須要注意,GraphQL 要求端點字段的類型必須是標量類型。(這裏的端點能夠理解爲葉子節點)
關於 GraphQL
的更多信息,請參考:graphql.cn/learn/
官網的介紹以下:
Apollo 是 GraphQL 的一個實現,可幫助您管理從雲到 UI 的數據。它能夠逐步採用,並在現有服務上進行分層,包括 REST API 和數據庫。Apollo 包括兩組用於客戶端和服務器的開源庫,以及開發人員工具,它提供了在生產中可靠地運行 GraphQL API 所需的一切。
咱們能夠將 Apollo
看做是一組工具,它分爲兩大類,一類面向服務端,另外一類面向客戶端。
其中,面向客戶端的 Apollo Client
涵蓋了如下工具和平臺:
面向服務端的 Apollo Server
涵蓋了如下平臺:
咱們在本文中會使用 Apollo
中針對 NodeJS
服務端 koa
框架的 apollo-server-koa
庫
關於 apollo server 和 apollo-server-koa 的更多信息請參考:
新建一個文件夾,我這裏新建了 graphql-server-demo 文件夾
mkdir graphql-server-demo
複製代碼
在文件夾內初始化項目:
cd graphql-server-demo && yarn init
複製代碼
安裝依賴:
yarn add koa graphql apollo-server-koa
複製代碼
新建 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
args
在 SDL
查詢中傳入的參數context
此參數會被提供給全部解析器,而且持有重要的上下文信息好比當前登入的用戶或者數據庫訪問對象info
一個保存與當前查詢相關的字段特定信息以及 schema 詳細信息的值啓動服務
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
。
啓動咱們剛纔建立的 GraphQL
後端服務:
➜ graphql-server-demo git:(master) ✗ node index.js
🚀 Server ready at http://localhost:4000/graphql
複製代碼
咱們在瀏覽器中打開地址 http://localhost:4000/graphql
這時,咱們會看到以下界面:
在左側輸入查詢參數:
{
book {
name
price
}
}
複製代碼
而後點擊中間那個按鈕來發出請求(話說這個按鈕設計得很像播放按鈕,以致於我第一次看到它,覺得這是一個視頻...),請求成功後,咱們會看到右側輸出告終果:
playground 還爲咱們提供了以下部分功能:
以下圖:
其中,DOCS
和 SCHEMA
中的內容是經過 GraphQL
的一個叫作 內省
(introspection
)的功能提供的。
內省
功能容許咱們經過客戶端查詢參數詢問 GraphQL Schema
支持哪些查詢,而 playground 在啓動的時候,就會預先發送 內省
請求,獲取 Schema
信息,並組織好 DOCS
和 SCHEMA
的內容結構。
關於
內省
更詳細的內容請參考: graphql.cn/learn/intro…
對於 playground 和 內省
,咱們但願只在開發和測試生產環境纔開啓它們,生產環境中咱們但願它們是關閉的。
咱們能夠在建立 Apollo Server
實例的時候,經過對應的開關(playground
和 introspection
),來屏蔽生產環境:
...
const isProd = process.env.NODE_ENV === 'production'
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: !isProd,
playground: !isProd
})
...
複製代碼
接下來,咱們來考慮一個比較常見的問題:
客戶端和服務端將 api 文檔共享以後,每每服務端的功能須要一些時間來研發,在功能研發完畢以前,客戶端其實是沒法從 api 請求到真實數據的,這個時候,爲了方便客戶端的研發工做,咱們會讓 api 返回一些假數據。
接下來,咱們看看在 GraphQL
服務端,怎麼作這件事。
使用基於 Apollo Server
的 GraphQL
服務端來實現 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
}
}
})
...
複製代碼
重啓服務,再次請求,咱們發現錯誤信息被格式化爲咱們預期的格式:
目前爲止,咱們搭建的 GraphQL
服務端還很是的簡陋:
├── index.js
├── package.json
└── yarn.lock
複製代碼
它還沒法應用到實際工程中,由於它太 自由
了,咱們須要爲它設計一些 規矩
,來幫助咱們更好得應對實際工程問題。
到這一節,相信讀者已經感受到 GraphQL
帶來了哪些心智模型的改變:
路由
的工做,部分變成了如今的組織 Schema
的工做控制器
的工做,部分變成了如今的組織 Resolver
的工做咱們來設計一個 規矩
,幫助咱們組織好 Schema
和 Resolver
:
src
,用於存放絕大部分工程代碼src
中新建文件夾 components
,用於存放數據實體schema.js
和 resolver.js
,他們分別存儲關於當前數據實體的 Schema
和 Resolver
的描述src/components
中新建文件夾 book
,並在其中新建 schema.js
和 resolver.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
複製代碼
接下來咱們調整代碼
先來看 GraphQL
入口文件 src/graphql/index.js
的職責:
Schema
和 Resolver
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
的值中分別在 Query
、Mutation
、Subscription
三個類型入口中定義了一個名爲 _
,類型爲 Boolean
的字段。
這個字段其實是一個佔位符,由於官方還沒有支持多個擴展(extend
)類型合併的方法,所以這裏咱們能夠先設置一個佔位符,以支持合併擴展(extend
)類型。
咱們來定義數據實體:book
的 Schema
和 Resolver
的內容:
// 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
最後,咱們來調整服務應用啓動文件的內容:
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
類型:
首先,咱們安裝一個第三方日期工具 moment
:
yarn add moment
複製代碼
接下來,在 src/graphql
中新建文件夾 scalars
mkdir src/graphql/scalars
複製代碼
咱們在 scalars
這個文件夾中存放自定義標量
在 scalars
中新建文件: index.js
和 date.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
實例時,咱們能夠指定:
name
description
parseValue
serialize
ast
中的字面量的處理函數,也就是 parseLiteral
(這是由於在 ast
中的值老是格式化爲字符串)
ast
即抽象語法樹,關於抽象語法樹的細節請參考:zh.wikipedia.org/wiki/抽象語法樹
最後,讓咱們將自定義標量 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 }
...
複製代碼
最後,咱們驗證一下,重啓服務,並請求 book
的 created
字段,咱們發現服務端已經支持 Date
類型了:
本小節,咱們來學習如何在 GraphQL
服務端實現登陸校驗功能
過去,咱們每一個具體的路由,對應一個具體的資源,咱們很容易爲一部分資源添加保護(要求登陸用戶纔有訪問權限),咱們只須要設計一箇中間件,並在每個須要保護的路由上添加一個標記便可。
GraphQL
打破了路由與資源對應的概念,它主張在 Schema
內部標記哪些字段是受保護的,以此來提供資源保護的功能。
咱們想要在 GraphQL
服務中實現登陸校驗的功能,就須要藉助於如下幾個工具:
首先,咱們定義一個 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
上就已經掛載了用戶信息
經過解析器的 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
屬性(用戶信息)
而後,咱們設計一個自定義指令 auth
(它是 authentication
的簡寫)
在 src/graphql
中新建文件夾 directives
,用來存放全部自定義指令:
mkdir src/graphql/directives
複製代碼
咱們在 directives
這個文件夾中存放自定義指令
在 directives
中新建文件: index.js
和 auth.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
中撰寫針對每一個捕獲到的字段上須要執行的認證邏輯
認證邏輯很是簡單,在字段原來的解析器基礎之上,包裝一層認證邏輯便可:
field
中獲取它的解析器,並將它臨時存儲在局部變量中,方便接下來使用它。若是獲取不到,則賦值默認的解析器 defaultFieldResolver
field
上的解析器屬性爲咱們自定義的函數,在函數內咱們經過 args[2]
訪問到了解析器函數的第三個形參,並從中獲取到掛載在 ctx
上的用戶信息AuthenticationError
錯誤在建立 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 }
複製代碼
咱們定義了兩個數據類型: Cat
和 Food
並定義了一個查詢: 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
字段,值爲最愛吃的食物的 idfakerIO
來模擬異步IOgetFoodById
提供根據食物 id 獲取食物信息的功能,每調用一次 getFoodById
函數,都將打印一條日誌到終端重啓服務,請求 cats
,咱們看到正常返回告終果:
咱們去看一下終端的輸出,發現:
--- enter getFoodById --- { id: 1 }
--- enter getFoodById --- { id: 2 }
--- enter getFoodById --- { id: 3 }
複製代碼
getFoodById
函數被分別調用了三次。
GraphQL
的設計主張爲每一個字段指定解析器,這致使了:
一個批量的請求,在關聯其它數據實體時,每一個端點都會形成一次 IO。
這就是 沒必要要的請求
,由於上面這些請求能夠合併爲一次請求。
咱們怎麼合併這些 沒必要要的請求
呢?
咱們能夠經過一個叫作 dataLoader
的工具來合併這些請求。
dataLoader
提供了兩個主要的功能:
本文中,咱們只使用它的 Batching
功能
關於
dataLoader
更多的信息,請參考: github.com/graphql/dat…
首先,咱們安裝 dataLoader
yarn add dataloader
複製代碼
接下來,咱們在 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