GraphQL從入門到實戰

image

前言

原本這篇文章準備51假期期間就發出來的,可是由於本身的筆記本電腦出了一點問題,因此拖到了如今😂。爲了你們更好的學習GraphQL,我寫一個先後端的GraphQL的Demo,包含了登錄,增長數據,獲取數據一些常見的操做。前端使用了Vue和TypeScript,後端使用的是Koa和GraphQL。前端

這個是預覽的地址: GraphQLDeom 默認用戶root,密碼rootgit

這個是源碼的地址: learn-graphqlgithub

GraphQL入門以及相關概念

什麼是GraphQL?

按照官方文檔中給出的定義, "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

全稱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]
}
  • 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?

image

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 })

鏈接數據源

image

咱們在構建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文件夾。下面一個請求的流程圖。

image

其餘

關於鑑權

關於鑑權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')
    }
  }
}
相關文章
相關標籤/搜索