NodeJS
首发于NodeJS
在Egg中使用GraphQL

在Egg中使用GraphQL

给 Egg 专栏的投稿

GraphQL使用 Schema 来描述数据,并通过制定和实现 GraphQL 规范定义了支持 Schema 查询的 DSQL (Domain Specific Query Language,领域特定查询语言,由 FACEBOOK 提出。

传统 web 应用通过开发服务给客户端提供接口是很常见的场景。而当需求或数据发生变化时,应用需要修改或者重新创建新的接口。长此以后,会造成服务器代码的不断增长,接口内部逻辑复杂难以维护。而 GraphQL 则通过以下特性解决这个问题:

  • 声明式。查询的结果格式由请求方(即客户端)决定而非响应方(即服务器端)决定。你不需要编写很多额外的接口来适配客户端请求
  • 可组合。GraphQL 的查询结构可以自由组合来满足需求。
  • 强类型。每个 GraphQL 查询必须遵循其设定的类型才会被执行。

也就是说,通过以上的三个特性,当需求发生变化,客户端只需要编写能满足新需求的查询结构,如果服务端能提供的数据满足需求,服务端代码几乎不需要做任何的修改。

本篇教程不会过多介绍 GraphQL 的概念,而会着重于讲解如何通过 eggjs 来搭建 GraphQL 查询服务。如果对 GraphQL 感兴趣可以参考文末的参考链接。

技术选型

我们会使用 GraphQL Tools配合 eggjs 完成 GraphQL 服务的搭建。 GraphQL Tools 建立了一种 GraphQL-first 的开发哲学,主要体现在以下三个方面:

  • 使用官方的 GraphQL schema 进行编程。 GraphQL Tools 提供工具,让你可以书写标准的 GraphQL schema,并完全支持里面的特性。
  • schema 与业务逻辑分离。 GraphQL Tools 建议我们把 GraphQL 逻辑分为四个部分: Schema, Resolvers, Models, 和 Connectors。
  • 为很多特殊场景提供标准解决方案。最大限度标准化 GraphQL 应用。

egg-graphql

egg-graphql 插件是阿里滨江这边一些 graphql 实践的集合。其中使用了 GraphQL Server 完成了 GraphQL 查询语言 DSQL 的解析。

同时会使用 dataloader 来优化数据缓存。

为了便于使用, egg-graphql在使用 apollo-graphql 推荐的开发最佳实践之外,还会自动将schema加载到server中,把connector挂载到上下文中,便于使用。

egg-graphql 在服务端搭建了一个符合 GraphQL 规范的接口服务器,而取数逻辑依然需要数据访问层的配合,所以可以配合一些 ORM 框架如 egg-sequelize 来进行开发。


安装与配置

安装对应的依赖 [egg-graphql] :

$ npm i --save egg-graphql

开启插件:

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

config/config.${env}.js 配置提供 graphql 的路由。

// config/config.${env}.js
exports.graphql = {
  router: '/graphql',
  // 是否加载到 app 上,默认开启
  app: true,
  // 是否加载到 agent 上,默认关闭
  agent: false,
};

在中间件中开启 graphql

exports.middleware = [ 'graphql' ];

配置完成之后,每个落到 /graphql的请求都会触发 GraphQL Schema 的查询。

使用方式

这里附上一个实例代码

github.com/freebyron/eg

请将 graphql 相关逻辑放到 app/graphql 下

目录结构如下

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

编写schema.graphql

GraphQL 使用 Schema 来描述数据。 这个 schema 表明了一个数据模型中,有哪些字段,GraphQL 类库的其他部分会来消费这个 Schema 对象。例子:

type User {
  id: ID!
  name: String!
  items: [Item!]
}

编写connector

编写完 schema 之后,graphql 知道有哪些数据了,但他还需要知道“如何去取”, connector 的角色就在于此。 connector 的职责就是“取数”, 他既可以调用 rpc 接口取数,又可以调用内置的 orm 插件去取数,还可以直接调用 egg 的 service。

rpc方式

import rp from 'request-promise';
const { GITHUB_API_KEY, GITHUB_API_SECRET } = process.env;
const qs = { GITHUB_API_KEY, GITHUB_API_SECRET };
export function getRepositoryByName(name) {
  return rp({
    uri: `https://api.github.com/repos/${name}`,
    qs,
  });
}

直接调用 service

'use strict';
class ArticleConnector {
    constructor(ctx) {
        this.ctx = ctx;
    }
    async getArticleInfoByService(iArticleID) {
        return await this.ctx.service.article.getArticleByID(iArticleID);
    }
  }
module.exports = ArticleConnector;

connector中使用dataloader

上文讲到,可以使用connector直接调用数据库进行取数操作。但使用 graphql 的查询,直接访问数据库会出现问题。

举个例子,以下 graphql schema 和 query 代码:

# schema
type User {
  name: String,
  friends: [User]
}
# query
{
  users {
    name
    friends {
      name
      friends {
        name
      }
    } 
  }
}

graphql 支持嵌套查询,有时候会出现严重的N+1查询性能问题,比如上一张图,查询了三次 User 表,而实际上只需要查询一次即可。

Dataloader 是 facebook 搞的一个 js 库,可以大幅降低数据库的访问频次,从而降低系统负载,经常在 Graphql 场景中使用。通过使用 dataloader,数据库的访问频次可以指数级别下降。

dataloader 是如何工作的呢,可以看下图:


对于 User 表的多次访问,通过 dataloader 去取,会自动合并为一个请求。dataloader 之所以可以实现这样的能力,是因为他把每一次数据请求,都推迟到 node 的 Next Tick 后集中批处理运行,这样就可以对请求进行加工合并。

下面贴一个使用 dataloader 写的 connector:

'use strict';
const DataLoader = require('dataloader');
class UserConnector {
  constructor(ctx) {
    this.ctx = ctx;
    this.loader = new DataLoader(this.fetch.bind(this));
  }
  fetch(ids) {
    const users = this.ctx.app.model.User.findAll({
      where: {
        id: {
          $in: ids,
        },
      },
    }).then(us => us.map(u => u.toJSON()));
    return users;
  }
  fetchByIds(ids) {
    return this.loader.loadMany(ids);
  }
  fetchById(id) {
    return this.loader.load(id);
  }
}
module.exports = UserConnector; 

我们通常的根据id取用户的逻辑通过fetch方法实现,之后封装为两个方法,将其用 dataloader 包裹。当用户需要批量获取时,直接调用 dataloader 包裹后的方法,即可自动进行优化。这样就避免了多次调用 fetch 方法,跟数据库建立多次查询了。

编写resolver

我们编写好取数逻辑后,就要对用户的查询进行处理了,这个处理代码称之为 resolver.

其实 resolver 非常简单,就是针对你暴露的查询接口,调用相应的connector去取数即可,如下:

'use strict';
module.exports = {
  Query: {
    user(root, { id }, ctx) {
      return ctx.connector.user.fetchById(id);
    },
  },
};

之后用户所有对 User schema 的 graphql query,都会通过 connector 去获取。由于 connector 已经挂载到上下文上,你可以直接使用 ctx.connector 引用。

完成一次查询

我们可以手动构造一个查询请求检验下

const query = JSON.stringify({
    query: `{ user(id: ${user.id}) { id name } }`,
}); // graphql 的 query ,可以通过工具或者自己构造出来
const data = yield ctx.service.graphql.query(query);//主查询方法

实际请求的时候不需要手动处理,只要请求落在了我们配置的路由上,就会自动调用ctx.service.graphql.query(query)方法。

整体流程

其他

那如果我们要对数据进行增删改,就需要使用 graphql 的 mutation方法了,可以参考 github.com/freebyron/eg

目前 graphql 已经在阿里多个 node 应用中进行试点,并且已经开源到 github.com/eggjs/egg-gr,感谢先行者同事 @邓若奇 的探索。

参考文章

编辑于 2017-12-13

文章被以下专栏收录

    在 eggjs 团队的日常协作中,遵循「基于 GitLab 的硬盘式异步协作模式」。 先通过 issue 发起 RFC 召集讨论,再提交 Pull Request 和 Code Review,这样便于沉淀,即使是当时没有参与讨论的开发者,事后也能通过 issue 了解某个功能设计的前因后果。 因此,本专栏用于汇总近期值得关注的 Egg.js 和 Node.js 相关动态,将不定期发布。