原本這篇文章準備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') } } }