回顾项目中实践的 graphql

背景

早些时候在公司内部的做过一个内部项目 api-docs,该项目是提供一个 Api 管理文档平台,类似于 YApi 。项目是半路接过来的,前端使用 vue,服务端使用 eggjs 提供符合 restful 规范的接口,数据库则选择的是 mongodb

在不断的迭代中,遇到以下一些问题

  1. 首页和测试模块都展示了 api 列表,但是首页与测试模块所展示的字段存在一些异同点,基于此,有两种做法:
    1. 提供两个不同的接口
    2. 提供同一个接口,根据请求参数判断需要下发的字段
  2. 需要对外开放查询接口,不同部门需求的字段信息不同
  3. 由于是内部使用,接口 ( 需求 ) 改动比较频繁,仅仅是修改前端展示的数据却需要频繁修改服务端接口,当然可以将页面需要的字段全部下发,不过这么做有点浪费带宽。

因此我引入了 graphql 来解决上述的问题。基本想法是前端自行构建需要的字段,服务端无需变更。

本文尝试去还原引入的过程是防止遗忘( 实际上时间隔得比较久了,一些细节确实已经模糊了 )。由于当时也是摸石头过河,可能会有一些不是最佳实践的地方,还望指教。

文中涉及到的库版本:

// 服务端
dataloader: 1.4.0
egg: 2.0.0
egg-graphql: 2.3.0
graphql-type-json: 0.2.1

// 前端
graphql:14.5.8
apollo-boost:0.1.23

前端处理

前端我是使用了 apollo 来发送请求的,引入 apollo

import Vue from "vue";
import VueApollo from "vue-apollo";
import ApolloClient from "apollo-boost";
import API from "@/config/api";

const apolloClient = new ApolloClient({
  uri: '/graphql'
});

const apolloProvider = new VueApollo({
  defaultClient: apolloClient
});

Vue.use(VueApollo);

export default apolloProvider;

构造一个查询 api 列表的语句

import gql from "graphql-tag";

const apiListQuery = gql`
    query($pageInfo: PageInfo){
      apis(pageInfo: $pageInfo){
        nodes{
          _id
          name
        }
        total
      }
    }
  `

还可以通过开发者工具 graphiql 快速构造正确的查询语句,这个后面会结合 eggjs 介绍。

发送请求

function getList = async () => {
  const pageInfo = {
    pageSize: 20,
    currentPage: 1
	};

  await apolloProvider.defaultClient.query({
    query: apiListQuery,
    variables: {
      pageInfo
    }
  });
}

或者简单通过 axios 发送

axios({
  url: 'http://xxxxx/graphql',
  method: 'POST',
  data: {
    query: `
            query ($filter: Api_Filter, $pageInfo: PageInfo) {
              apis(filter: $filter, pageInfo: $pageInfo) {
                  nodes {
                      _id
                      name
                  }
                  total
              }
          }`,
    variables: {
      pageInfo: {
        pageSize: 20,
        currentPage: 1,
      }
    },
  },
});

服务端处理

仅改造旧接口中有上述需求的 get 请求,对于其他没有必要全部转为 graphql 的接口不做变更。

egg 对 graphql 做了一定的支持: https://github.com/eggjs/egg-graphql,其中的配置可以参考文档。

// congfig.js
graphql: {
  router: '/graphql',
  // 是否加载到 app 上,默认开启
  app: true,
  // 是否加载到 agent 上,默认关闭
  agent: false,
  // 是否加载开发者工具 graphiql, 默认开启。路由同 router 字段。使用浏览器打开该可见。
  graphiql: true
}

// plugin
exports.graphql = {
  enable: true,
  package: 'egg-graphql'
}

目录结构:

.
├── app
│   ├── graphql
│   │   ├── project
│   │   │   └── schema.graphql
│   │   ├── api  // api 模型
│   │   │   ├── connector.js  
│   │   │   ├── resolver.js
│   │   │   └── schema.graphql
│   ├── model
│   │   └── api.js
│   ├── public
│   └── router.js

在 app 下新增一个 graphql 文件夹。

下面再新建一个 api 文件夹,根据 GraphQL 的规范,将 GraphQL 相关逻辑分成 Schema, Resolvers, Models, 和 Connectors。

schema.graphql 中描述数据模型,有点像 typescript 中的 interface。

graphql 的数据模型入口在 project/schema.graphql

type Apis {
  nodes: [Api],
  total: Int
}

type Query {
  apis(filter: Api_Filter, pageInfo: PageInfo): Apis
}

api / schema.graphql 的内容如下

type Api_Options {
	// 。。。
}

type Api {
  _id: String
  name: String
  options: Api_Options
}

声明的 type 可以被别的 type 引用。

resolver 是对接到用户查询

module.exports = {
  Query: {
    apis (root, params, ctx) {
      return ctx.connector.api.find(params)
    },
  },
}

上面我们看到 ctx 有个 connector 属性,实际上对应的就是我们的 connector 文件,connector 主要负责取数逻辑,类似于 controller 的职能。

class ApiConnector {
  constructor (ctx) {
    this.ctx = ctx
  }

  async find (params) {
    // 获取到参数
    const { pageInfo } = params
    const { currentPage, pageSize } = pageInfo

    const apis = await this.ctx.model.Api.find()
      .sort({
        createTime: -1
      })
      .skip((Number(currentPage) - 1) * Number(pageSize))
      .limit(Number(pageSize))
      .exec()

    const total = await this.ctx.model.Api.find()
      .count()
      .exec()

    return {
      nodes: apis,
      total
    }
  }
}
module.exports = ApiConnector

至此完成了一个简单的发 graphql 请求,到服务端处理的并返回的过程。

注意点

DataLoader:graphql 支持嵌套查询,有时候会出现严重的N+1查询性能问题。为了解决这个问题,facebook 提出 dataloader 这个方案。

  1. 具体可以看 https://zhuanlan.zhihu.com/p/30604868 的说明。
  2. dataloader 要求返回的结果和传入的 key 一致,而 mongo find 与 $in 是不保证顺序的,请注意要转成正确的顺序。