GraphQL從入門到實戰

image

前言

本來這篇文章準備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?

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')
    }
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章