原本這篇文章準備51假期期間就發出來的,可是由於本身的筆記本電腦出了一點問題,因此拖到了如今😂。爲了你們更好的學習GraphQL,我寫一個先後端的GraphQL的Demo,包含了登錄,增長數據,獲取數據一些常見的操做。前端使用了Vue和TypeScript,後端使用的是Koa和GraphQL。前端
這個是預覽的地址: GraphQLDeom 默認用戶root,密碼rootgit
這個是源碼的地址: learn-graphqlgithub
按照官方文檔中給出的定義, "GraphQL 既是一種用於 API 的查詢語言也是一個知足你數據查詢的運行時。 GraphQL 對你的 API 中的數據提供了一套易於理解的完整描述,使得客戶端可以準確地得到它須要的數據,並且沒有任何冗餘,也讓 API 更容易地隨着時間推移而演進,還能用於構建強大的開發者工具"。可是我在使用以後發現,gql須要後端作的太多了,類型系統對於前端很美好,可是對於後端來講可能意味着屢次的數據庫查詢。雖然gql實現了http請求上的優化,可是後端io的性能也應當是咱們所考慮的。web
GraphQL中操做類型主要分爲查詢和變動(還有subscription訂閱),分別對應query,mutation關鍵字。query,mutation的操做名稱operation name是能夠省略的。可是添加操做名稱能夠避免歧義。操做能夠傳遞不一樣的參數,例如getHomeInfo中分頁參數,AddNote中筆記的屬性參數。下文中,咱們主要對query和mutation進行展開。redis
query getHomeInfo {
users(pagestart: ${pagestart}, pagesize: ${pagesize}) {
data {
id
name
createDate
}
}
}
mutation AddNote {
addNote(note: {
title: "${title}",
detail: "${detail}",
uId: "${uId}"
}) {
code
}
}
複製代碼
全稱Schema Definition Language。GraphQL實現了一種可讀的模式語法,SDL和JavaScript相似,這種語法必須存儲爲String格式。咱們須要區分GraphQL Schema和Mongoose Schema的區別。GraphQL Schema聲明瞭返回的數據和結構。Mongoose Schema則聲明瞭數據存儲結構。數據庫
GraphQL提供了一些默認的標量類型, Int, Float, String, Boolean, ID。GraphQL支持自定義標量類型,咱們會在後面介紹到。npm
對象類型是Schema中最多見的類型,容許嵌套和循環引用json
type TypeName {
fieldA: String
fieldB: Boolean
fieldC: Int
fieldD: CustomType
}
複製代碼
查詢類型用於獲取數據,相似REST GET。Query是Schema的起點,是根級類型之一,Query描述了咱們能夠獲取的數據。下面的例子中定義了兩種查詢,getBooks,getAuthors。後端
type Query {
getBooks: [Book]
getAuthors: [Author]
}
複製代碼
傳統的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
}
複製代碼
解析器提供了將gql的操做(查詢,突變或訂閱)轉換爲數據的行爲,它們會返回咱們在Scheam的指定的數據,或者該數據的Promise。解析器擁有四個參數,parent, args, context, info。
咱們沒有爲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是一個開源的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)
複製代碼
從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
}
})
複製代碼
目前咱們還不能運行查詢或者變動。咱們如今須要編寫解析器。在以前的介紹中,咱們知道了解析器提供了將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鑑權適用於不對外公共的服務, 這是一種全有或者全無的鑑權方式。若是須要實現這種鑑權只須要修改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以外受權。我會在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')
}
}
}
複製代碼