前言
本來這篇文章準備51假期期間就發出來的,但是因爲自己的筆記本電腦出了一點問題,所以拖到了現在😂。爲了大家更好的學習GraphQL,我寫一個前後端的GraphQL的Demo,包含了登陸,增加數據,獲取數據一些常見的操作。前端使用了Vue和TypeScript,後端使用的是Koa和GraphQL。
這個是預覽的地址: GraphQLDeom 默認用戶root,密碼root
這個是源碼的地址: learn-graphql
GraphQL入門以及相關概念
什麼是GraphQL?
按照官方文檔中給出的定義, "GraphQL 既是一種用於 API 的查詢語言也是一個滿足你數據查詢的運行時。 GraphQL 對你的 API 中的數據提供了一套易於理解的完整描述,使得客戶端能夠準確地獲得它需要的數據,而且沒有任何冗餘,也讓 API 更容易地隨着時間推移而演進,還能用於構建強大的開發者工具"。但是我在使用之後發現,gql需要後端做的太多了,類型系統對於前端很美好,但是對於後端來說可能意味着多次的數據庫查詢。雖然gql實現了http請求上的優化,但是後端io的性能也應當是我們所考慮的。
查詢和變更
GraphQL中操作類型主要分爲查詢和變更(還有subscription訂閱),分別對應query,mutation關鍵字。query,mutation的操作名稱operation name是可以省略的。但是添加操作名稱可以避免歧義。操作可以傳遞不同的參數,例如getHomeInfo中分頁參數,AddNote中筆記的屬性參數。下文中,我們主要對query和mutation進行展開。
query getHomeInfo {
users(pagestart: ${pagestart}, pagesize: ${pagesize}) {
data {
id
name
createDate
}
}
}
mutation AddNote {
addNote(note: {
title: "${title}",
detail: "${detail}",
uId: "${uId}"
}) {
code
}
}
Schema
全稱Schema Definition Language。GraphQL實現了一種可讀的模式語法,SDL和JavaScript類似,這種語法必須存儲爲String格式。我們需要區分GraphQL Schema和Mongoose Schema的區別。GraphQL Schema聲明瞭返回的數據和結構。Mongoose Schema則聲明瞭數據存儲結構。
類型系統
標量類型
GraphQL提供了一些默認的標量類型, Int, Float, String, Boolean, ID。GraphQL支持自定義標量類型,我們會在後面介紹到。
對象類型
對象類型是Schema中最常見的類型,允許嵌套和循環引用
type TypeName {
fieldA: String
fieldB: Boolean
fieldC: Int
fieldD: CustomType
}
查詢類型
查詢類型用於獲取數據,類似REST GET。Query是Schema的起點,是根級類型之一,Query描述了我們可以獲取的數據。下面的例子中定義了兩種查詢,getBooks,getAuthors。
type Query {
getBooks: [Book]
getAuthors: [Author]
}
- getBooks,獲取book列表
- getAuthors,獲取作者的列表
傳統的REST API如果要獲取兩個列表需要發起兩次http請求, 但是在gql中允許在一次請求中同時查詢。
query {
getBooks {
title
}
getAuthors {
name
}
}
突變類型
突變類型類似與REST API中POST,PUT,DELETE。與查詢類型類似,Mutation是所有指定數據操作的起點。下面的例子中定義了addBook mutation。它接受兩個參數title,author均爲String類型,mutation將會返回Book類型的結果。如果突變或者查詢需要對象作爲參數,我們則需要定義輸入類型。
type Mutation {
addBook(title: String, author: String): Book
}
下面的突變操作中會在添加操作後,返回書的標題和作者的姓名
mutation {
addBook(title: "Fox in Socks", author: "Dr. Seuss") {
title
author {
name
}
}
}
輸入類型
輸入類型允許將對象作爲參數傳遞給Query和Mutation。輸入類型爲普通的對象類型,使用input關鍵字進行定義。當不同參數需要完全相同的參數的時候,也可以使用輸入類型。
input PostAndMediaInput {
title: String
body: String
mediaUrls: [String]
}
type Mutation {
createPost(post: PostAndMediaInput): Post
}
如何描述類型?(註釋)
Scheam中支持多行文本和單行文本的註釋風格
type MyObjectType {
"""
Description
Description
"""
myField: String!
otherField(
"Description"
arg: Int
)
}
🌟自定義標量類型
如何自定義標量類型?我們將下面的字符串添加到Scheam的字符串中。MyCustomScalar是我們自定義標量的名稱。然後需要在 resolver中傳遞GraphQLScalarType的實例,自定義標量的行爲。
scalar MyCustomScalar
我們來看下把Date類型作爲標量的例子。首先在Scheam中添加Date標量
const typeDefs = gql`
scalar Date
type MyType {
created: Date
}
`
接下來需要在resolvers解釋器中定義標量的行爲。坑爹的是文檔中只是簡單的給出了示例,並沒有解釋一些參數的具體作用。我在stackoverlfow上看到了一個不錯的解釋。
serialize是將值發送給客戶端的時候,將會調用該方法。parseValue和parseLiteral則是在接受客戶端值,調用的方法。parseLiteral則會對Graphql的參數進行處理,參數會被解析轉換爲AST抽象語法樹。parseLitera會接受ast,返回類型的解析值。parseValue則會對變量進行處理。
const { GraphQLScalarType } = require('graphql')
const { Kind } = require('graphql/language')
const resolvers = {
Date: new GraphQLScalarType({
name: 'Date',
description: 'Date custom scalar type',
// 對來自客戶端的值進行處理, 對變量的處理
parseValue(value) {
return new Date(value)
},
// 對返回給客戶端的值進行處理
serialize(value) {
return value.getTime()
},
// 對來自客戶端的值進行處理,對參數的處理
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return parseInt(ast.value, 10)
}
return null
},
}),
}
接口
接口是一個抽象類型,包含了一些字段,如果對象類型需要實現這個接口,需要包含這些字段
interface Avengers {
name: String
}
type Ironman implements Avengers {
id: ID!
name: String
}
解析器 resolvers
解析器提供了將gql的操作(查詢,突變或訂閱)轉換爲數據的行爲,它們會返回我們在Scheam的指定的數據,或者該數據的Promise。解析器擁有四個參數,parent, args, context, info。
- parent,父類型的解析結果
- args,操作的參數
- context,解析器的上下文,包含了請求狀態和鑑權信息等
- info,Information about the execution state of the operation which should only be used in advanced cases
默認解析器
我們沒有爲Scheam中所有的字段編寫解析器,但是查詢依然會成功。gql擁有默認的解析器。如果父對象擁有同名的屬性,則不需要爲字段編寫解釋器。它會從上層對象中讀取同名的屬性。
類型解析器
我們可以爲Schema中任何字段編寫解析器,不僅僅是查詢和突變。這也是GraphQL如此靈活的原因。
下面例子中,我們爲性別gender字段單獨編寫解析器,返回emoji表情。gender解析器的第一個參數是父類型的解析結果。
const typeDefs = gql`
type Query {
users: [User]!
}
type User {
id: ID!
gender: Gender
name: String
role: Role
}
enum Gender {
MAN
WOMAN
}
type Role {
id: ID!
name: String
}
`
const resolves = {
User: {
gender(user) {
const { gender } = user
return gender === 'MAN' ? '👨' : '👩'
}
}
}
ApolloServer
什麼是ApolloServer?
ApolloServer是一個開源的GraphQL框架,在ApolloServer 2中。ApolloServer可以單獨的作爲服務器,同時ApolloServer也可以作爲Express,Koa等Node框架的插件
快速構建
就像我們之前所說的一樣。在ApolloServer2中,ApolloServer可以單獨的構建一個GraphQL服務器(具體可以參考Apollo的文檔)。但是我在個人的demo項目中,考慮到了社區活躍度以及中間件的豐富度,最終選擇了Koa2作爲開發框架,ApolloServer作爲插件使用。下面是Koa2與Apollo構建服務的簡單示例。
const Koa = require('koa')
const { ApolloServer } = require('apollo-server-koa')
const typeDefs = require('./schemas')
const resolvers = require('./resolvers')
const app = new Koa()
const mode = process.env.mode
// KOA的中間件
app.use(bodyparser())
app.use(response())
// 初始化REST的路由
initRouters()
// 創建apollo的實例
const server = new ApolloServer({
// Schema
typeDefs,
// 解析器
resolvers,
// 上下文對象
context: ({ ctx }) => ({
auth: ctx.req.headers['x-access-token']
}),
// 數據源
dataSources: () => initDatasource(),
// 內省
introspection: mode === 'develop' ? true : false,
// 對錯誤信息的處理
formatError: (err) => {
return err
}
})
server.applyMiddleware({ app, path: config.URL.graphql })
module.exports = app.listen(config.URL.port)
構建Schema
從ApolloServer中導出gql函數。並通過gql函數,創建typeDefs。typeDefs就是我們所說的SDL。typeDefs中包含了gql中所有的數據類型,以及查詢和突變。可以視爲所有數據類型及其關係的藍圖。
const { gql } = require('apollo-server-koa')
const typeDefs = gql`
type Query {
# 會返回User的數組
# 參數是pagestart,pagesize
users(pagestart: Int = 1, pagesize: Int = 10): [User]!
}
type Mutation {
# 返回新添加的用戶
addUser(user: User): User!
}
type User {
id: ID!
name: String
password: String
createDate: Date
}
`
module.exports = typeDefs
由於我們需要把所有數據類型,都寫在一個Schema的字符串中。如果把這些數據類型都在放在一個文件內,對未來的維護工作是一個障礙。我們可以藉助merge-graphql-schemas,將schema進行拆分。
const { mergeTypes } = require('merge-graphql-schemas')
// 多個不同的Schema
const NoteSchema = require('./note.schema')
const UserSchema = require('./user.schema')
const CommonSchema = require('./common.schema')
const schemas = [
NoteSchema,
UserSchema,
CommonSchema
]
// 對Schema進行合併
module.exports = mergeTypes(schemas, { all: true })
連接數據源
我們在構建Scheam後,需要將數據源連接到Scheam API上。在我的demo示例中,我將GraphQL API分層到REST API的上面(相當於對REST API做了聚合)。Apollo的數據源,封裝了所有數據的存取邏輯。在數據源中,可以直接對數據庫進行操作,也可以通過REST API進行請求。我們接下來看看如何構建一個REST API的數據源。
// 安裝apollo-datasource-rest
// npm install apollo-datasource-rest
const { RESTDataSource } = require('apollo-datasource-rest')
// 數據源繼承RESTDataSource
class UserAPI extends RESTDataSource {
constructor() {
super()
// baseURL是基礎的API路徑
this.baseURL = `http://127.0.0.1:${config.URL.port}/user/`
}
/**
* 獲取用戶列表的方法
*/
async getUsers (params, auth) {
// 在服務內部發起一個http請求,請求地址 baseURL + users
// 我們會在KoaRouter中處理這個請求
let { data } = await this.get('users', params, {
headers: {
'x-access-token': auth
}
})
data = Array.isArray(data) ? data.map(user => this.userReducer(user)) : []
// 返回格式化的數據
return data
}
/**
* 對用戶數據進行格式化的方法
*/
userReducer (user) {
const { id, name, password, createDate } = user
return {
id,
name,
password,
createDate
}
}
}
module.exports = UserAPI
現在一個數據源就構建完成了,很簡單吧😊。我們接下來將數據源添加到ApolloServer上。以後我們可以在解析器Resolve中獲取使用數據源。
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ ctx }) => ({
auth: ctx.req.headers['x-access-token']
}),
// 添加數據源
dataSources: () => {
UserAPI: new UserAPI()
},
introspection: mode === 'develop' ? true : false,
formatError: (err) => {
return err
}
})
編寫resolvers
目前我們還不能運行查詢或者變更。我們現在需要編寫解析器。在之前的介紹中,我們知道了解析器提供了將gql的操作(查詢,突變或訂閱)轉換爲數據的行爲。解析器主要分爲三種,查詢解析器,突變解析器,類型解析器。下面是一個查詢解析器和突變解析器的示例,它分別位於解析器對象的Query字段,Mutation字段中。因爲是根解析器,所以第一個parent爲空。第二個參數,是查詢或變更傳遞給我們的參數。第三個參數則是我們apollo的上下文context對象,我們可以從上下文對象上拿到之前我們添加的數據源。解析器需要返回符合Scheam模式的數據,或者該數據的Promise。突變解析器,查詢解析器中的字段應當和Scheam中的查詢類型,突變類型的字段是對應的。
module.exports = {
// 查詢解析器
Query: {
users (_, { pagestart, pagesize }, { dataSources, auth }) {
// 調用UserAPI數據源的getUsers方法, 返回User的數組
return dataSources.UserAPI.getUsers({
pagestart,
pagesize
}, auth)
}
},
// 突變解析器
Mutation: {
// 調用UserAPI數據源的addUser方法
addUser (_, { user }, { dataSources, auth }) {
return dataSources.UserAPI.addUser(user, auth)
}
}
}
我們接着將解析器連接到AppleServer中。
const server = new ApolloServer({
// Schema
typeDefs,
// 解析器
resolvers,
// 添加數據源
dataSources: () => {
UserAPI: new UserAPI()
}
})
好了到了目前爲止,graphql這一層我們基本完善了,我們的graphql層最終會在數據源中調用REST API接口。接下來的操作就是傳統的MVC的那一套。相信熟悉Koa或者Express的小夥伴一定都很熟悉。如果有不熟悉的小夥伴,可以參閱源碼中routes文件夾以及controller文件夾。下面一個請求的流程圖。
其他
關於鑑權
關於鑑權Apollo提供了多種解決方案。
Schema鑑權
Schema鑑權適用於不對外公共的服務, 這是一種全有或者全無的鑑權方式。如果需要實現這種鑑權只需要修改context
const server = new ApolloServer({
context: ({ req }) => {
const token = req.headers.authorization || ''
const user = getUser(token)
// 所有的請求都會經過鑑權
if (!user) throw new AuthorizationError('you must be logged in');
return { user }
}
})
解析器鑑權
更多的情況下,我們需要公開一些無需鑑權的API(例如登錄接口)。這時我們需要更精細的權限控制,我們可以將權限控制放到解析器中。
首先將權限信息添加到上下文對象上
const server = new ApolloServer({
context: ({ ctx }) => ({
auth: ctx.req.headers.authorization
})
})
針對特定的查詢或者突變的解析器進行權限控制
const resolves = {
Query: {
users: (parent, args, context) => {
if (!context.auth) return []
return ['bob', 'jake']
}
}
}
GraphQL之外的授權
我採用的方案,是在GraphQL之外授權。我會在REST API中使用中間件的形式進行鑑權操作。但是我們需要將request.header中包含的權限信息傳遞給REST API
// 數據源
async getUserById (params, auth) {
// 將權限信息傳遞給REST API
const { data } = await this.get('/', params, {
headers: {
'x-access-token': auth
}
})
data = this.userReducer(data)
return data
}
// *.router.js
const Router = require('koa-router')
const router = new Router({ prefix: '/user' })
const UserController = require('../controller/user.controller')
const authentication = require('../middleware/authentication')
// 適用鑑權中間件
router.get('/users', authentication(), UserController.getUsers)
module.exports = router
// middleware authentication.js
const jwt = require('jsonwebtoken')
const config = require('../config')
const { promisify } = require('util')
const redisClient = require('../config/redis')
const getAsync = promisify(redisClient.get).bind(redisClient)
module.exports = function () {
return async function (ctx, next) {
const token = ctx.headers['x-access-token']
let decoded = null
if (token) {
try {
// 驗證jwt
decoded = await jwt.verify(token, config.jwt.secret)
} catch (error) {
ctx.throw(403, 'token失效')
}
const { id } = decoded
try {
// 驗證redis存儲的jwt
await getAsync(id)
} catch (error) {
ctx.throw(403, 'token失效')
}
ctx.decoded = decoded
// 通過驗證
await next()
} else {
ctx.throw(403, '缺少token')
}
}
}