回顾项目中实践的 graphql
背景
早些时候在公司内部的做过一个内部项目 api-docs,该项目是提供一个 Api 管理文档平台,类似于 YApi 。项目是半路接过来的,前端使用 vue,服务端使用 eggjs 提供符合 restful 规范的接口,数据库则选择的是 mongodb。
在不断的迭代中,遇到以下一些问题
- 首页和测试模块都展示了 api 列表,但是首页与测试模块所展示的字段存在一些异同点,基于此,有两种做法:
- 提供两个不同的接口
- 提供同一个接口,根据请求参数判断需要下发的字段
- 需要对外开放查询接口,不同部门需求的字段信息不同
- 由于是内部使用,接口 ( 需求 ) 改动比较频繁,仅仅是修改前端展示的数据却需要频繁修改服务端接口,当然可以将页面需要的字段全部下发,不过这么做有点浪费带宽。
因此我引入了 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 这个方案。
- 具体可以看 https://zhuanlan.zhihu.com/p/30604868 的说明。
- dataloader 要求返回的结果和传入的 key 一致,而 mongo find 与 $in 是不保证顺序的,请注意要转成正确的顺序。