GraphQL从入门到实战

41次阅读

共计 9365 个字符,预计需要花费 24 分钟才能阅读完成。

前言

本来这篇文章准备 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')
    }
  }
}

正文完
 0