GraphQL:一种不同于REST的接口风格

从去年开始,JS算是完全踏入ES6时代。在React相关项目中接触到了一些ES6的语法。这次接着GraphQL这种新型的接口风格,从后端的角度接触ES6。

这篇文章从ES6的特征讲起,打好语法基础;然后引用GraphQL的规范说明;最后实验性质地在node环境下实践GraphQL这种接口风格,作为接下来重构接口工作的起点。

  1. ES6
  2. GraphQL
  3. Node ES6语法环境
  4. 搭建GraphQL Server

ES6

babel learning page

ES6也就是ECMAScript2015于2015年6月正式发布,这是最新的Javascript核心语言标准。新的语法规范涵盖各种语法糖和新概念。ES6既兼容过去编写的JS代码,又以一种新的方式彻底改JS代码。ES6始终坚持这样的宗旨:

凡是新加入的特性,势必已在其它语言中得到强有力的实用性证明。

下面依据Babeljs的文档介绍ES6的新特性。

Arrows:箭头函数

能够编写lambda函数的新语法,它的语法非常简单:标志符=>表达式。表达式可以是返回值,也可以是块语句(块语句需要使用return手动返回)。当然要注意下列代码出现的情况。由于空对象与块语句的符号都是使用{}标志,箭头函数看到{}会判定为空语法块,需要强制使用括号包裹空对象。

let items = Objs.map(stuff => {});  //空语法块  
let items = Objs.map(stuff => ({})); //空对象  

并且,箭头函数的this值继承外围作用域,共享父函数的“arguments”参数变量。

Class:类

我们知道在ES5中我们用多种方式实现函数的构造,这些分发看起来都比较复杂。ES6提供了一种原型OO的语法糖。比如使用static添加方法时,函数的.prototype属性也能添加相应的方法。

Subclassing:子类

ES5中原有的继承方式是这样的:

为了使新创建的类继承所有的静态属性,我们需要让这个新的函数对象继承超类的函数对象;同样,为了使新创建的类继承所有实例方法,我们需要让新函数的prototype对象继承超类的prototype对象。

ES6添加使用关键词‘extends’声明子类继承父类,使用关键词‘super’访问父类的属性。而父类可以使用new.target来确定子类的类型。

Template String:模板字符串

`Hello, This is template of ${language}?` 这种使用反引号的字符串就是模板字符串,它为JS提供了简单的字符串插值。

Destructuring:解构

解构赋值允许你使用类似数组或者对象字面量的语法将数组和对象的属性赋给各种变量。

let [foo, [[bar], baz]] = [1, [[2], 3]];  //嵌套数据解构  
let { name: nameA } = { name: 'Ips' }  //对象解构  

解构还可以应用到交换变量、函数返回多值、函数参数默认值(like python),使编写的代码更加简洁。

Symbols:符号

JS的第七种类型的原始量,能够避免冲突的风险地创建作为属性键的值险。

Iterators:迭代器

ES6增加了新的一种循环语法 for-of。该方法可以正确响应break、continue、return。

向对象添加Symbol.iterator,就可以遍历对象。迭代器对象是具有.next()方法的对象。for-of首次调用集合的Symbol.iterator()方法,紧接着返回一个新的迭代器对象。for-of循环每次调用.next()方法。比如下面这个迭代器实现了每次返回0。

let objIterator = {  
  [Symbol.iterator]: function(){
    return this;
  };
  next: function(){
    return { done: false, value: 0 };
  }
}

Generators:生成器

生成器就是包含 yield 表达式的函数。yield类似return,不过在生成器的执行过程肿,遇到yield时立即暂停,后续可以恢复执行状态。普通函数使用function声明,而生成器函数使用function*声明。

所有的生成器都有内建.next()和Symbol.interator方法的实现,所以生成器就是迭代器

Modules:模块

模块标志就是一段脚本,Node采用CommonJS的方式模块化。在ES6中的模块默认在严格模式下运行模块,并且可以使用关键词‘import’和‘export’。‘export’可以导出最外层的函数、类以及var、let或者const声明的变量。‘import’可以直接导入或者导入模块内部多个模块、重命名模块。除了node使用‘require’关键字外,ES6的模块和node的是一样的。

当JS引擎运行模块时,按照下列四个步骤执行:

  1. 语法解析:阅读模块源代码,检查语法错误。
  2. 加载:递归地加载所有被导入的模块。这也正是没被标准化的部分。
  3. 连接:每遇到一个新加载的模块,为其创建作用域并将模块内声明的所有绑定填充到该作用域中,其中包括由其它模块导入的内容。
  4. 运行时:最终,在每一个新加载的模块体内执行所有语句。

Proxies:代理

代理(Proxy)对象作为定义对象基础操作(get、set、has等总共14个方法名称)的全局构造函数。它接受两个参数:目标对象与句柄对象。

var p = new Proxy(target, handler);

代理的行为很简单:将代理的所有内部方法转发到目标对象。而句柄对象是用来覆写任意代理的内部方法。

Reflect:反射

ES6的Reflect对象提供对任意对象进行某种特定的可拦截操作(interceptable operation)。Reflect对象提供14个与代理方法名字相同的方法,可以方便的管理对象。使用时直接通过Reflect.method()这样来调用。

Promises:

Promise代表某个未来才会结束的事件的结果,这通常是异步的。ES
6提供Promise后,就可以将异步操作以同步操作的流程表达出来。Promise接受一个executor参数。executor带有resolve、reject参数,resolve失成功的回调函数,reject是失败的回调函数。

Promise对象是一个返回值的代理,这个返回值在promise对象创建时是未知的。

如图,Promise对象有:pending、fulfilled、rejected状态。pending状态可以转换成带成功值的fulfilled状态,也可以转换成带失败信息的rejected状态。当状态发生变化时,就会调用绑定在.then上的方法。

promise from mdn

创建一个Promise:

let p = new Promise(function(resolve, reject) {  
  if (/* condition */) {
    resolve(/* value */);  // fulfilled successfully
  }
  else {
    reject(/* reason */);  // error, rejected
  }
});

Promise的.then()方法接受两个参数:第一个函数当Promise成功(fulfilled)时调用,第二个函数当Promise失败(rejected)时掉用。

p.then((val) => console.log("fulfilled:", val),  
       (err) => console.log("rejected: ", err));

上述代码等价于

p.then((val) => console.log("fulfilled:", val))  
 .catch((err) => console.log("rejected:", err));

Others:新增数值字面量、数据结构、库函数

ES6还有一些新增的特性,这些都是不对语言原有的内容进行冲突而加入的补充功能。

GraphQL

GraphQL是一种API查询语言,也是开发者定义数据的类型系统在服务器端的运行时。

GraphQL分为定义数据和查询交互过程。比如定义一个包含两个字段的User类型的GraphQL service,其提供数据结构和处理该类型各字段的函数。

type User{  
  id: ID
  name: String
}
function User_name(user){  
  return user.getName();
}

而查询的方式与json类型有点相似。

{
  user{
    name
  }
}

查询返回的数据可以以一个json对象的形式表达。

{
  "data": {
    "user": {
      "name": "Leo"
    }
  }
}

@medium

上图来自medium的文章。GraphQL的查询与Rest风格是不一样的。Rest的数据是以资源为导向的,交互围绕着定位资源的路由(Route)进行;而GraphQL的模型与对象模型更加类似,模型是通过图的形式组织数据。相比Rest在客户端定义响应数据的结构,GraphQL灵活地将响应数据的结构交给了客户端。这样的好处是:客户端只需要一次请求就能够获得结构复杂的数据。

GraphQL有着自己的规范。依据官网给出的主要概念,规范文档主要分为查询操作和封装数据的类型系统两方面的内容。

Query and Mutation:查询和修改

查询和修改都是针对GraphQL服务器的查询操作。

Field:字段

GraphQL对数据对象的指定字段进行操作。

除了上一节的查询,还可以对内嵌对象、数组进行查询:

{
    user {
        name
    friends {
        name
    }
    }
}

其json格式的结果如下:

{
    "data": {
        "user":{
        "name": "Leo",
        "friends": {
        […]
        }
    }   
    }
}

Arguments:查询参数

查询语法还支持传递参数,并且参数也是可以嵌套的。

{
    user(id: "1003"){
        name
    }
}

Aliases:别名

如同SQL的AS作别名功能一样,我们可以对每一个查询字段的:前面添上别名。

{
    Chinese: user(nation: "china"){
        name
    }
}

Fragments:片段

片段可以构造查询需要的字段,用分割复杂应用所需的数据来提高查询语句的复用程度。

{
    Chinese: user(nation: "china"){
        ...comparisonFields
    }
    American: user(nation: "America"){
        ...comparisonFields
    }
}
fragment comparisonFields on User{  
    name
    age
    speaksLanguage
}

Variables:查询中的变量

为了动态传递参数,GraphQL提供了查询语言设置变量的功能,查询以字典的形式传递变量。

query UserNameAndFriends($age: Age) {  //变量定义: 变量以$前缀,后接类型  
  user(age: $age) {
    name
    friends {
      name
    }
  }
}
{
  “age”: 26
}

Directives:指令

在查询中标记字段的指令,可以改变查询的结构。比如下述这两种指令就能控制字段是否返回。

  • @include(if: Boolean) 条件为真时,只返回当前字段
  • @skip(if: Boolean) 条件为真时,过滤掉该字段

Mutations:修改数据

就像Rest以PUT/POST约定为修改服务器端数据一样,Mutations操作在GraphQL的意义就是修改数据库。就像官网中的例子:

mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) { //!表示必须填写的查询条件  
  createReview(episode: $ep, review: $review) {
    stars
    commentary
  }
}
{
  "ep": "JEDI",
  "review": {
    "stars": 5,
    "commentary": "This is a great movie!"
  }
}

需要注意的是,为了保证mutation操作不冲突,mutation只能序列执行。而query可以并行。

Inline Fragments:内联片段

使用内联片段返回接口或者联合类型(interface、union)的数据。如果查询接口或者联合类型的字段,会返回其具体的类型。比如下方的例子,这个查询的fragment以 ... on Droid 标记,表示当Hero的Character是Droid类型时primaryFunction字段才会被执行。同样的,height字段只有在Human类型下才显示。

query HeroForEpisode($ep: Episode!) {  
  hero(episode: $ep) {
    name
    ... on Droid {
      primaryFunction
    }
    ... on Human {
      height
    }
  }
}

Meta fields:元字段

元字段用来描述查询中的各个字段。比如当Query查询__typename时,服务器端就会返回响应的数据类型。

Schema and Type:数据结构和类型

GraphQL有着自己的类型系统来描述被查询的数据。

Type system:类型系统

当接收到客户端发送的查询时,服务器毁从指定的‘root’对象开始,一层层选择查询字段。GraphQL的结合与返回结果类似,客户端通过schema可以预知服务器大概返回的结果。

Type language:类型语言

GraphQL不依赖特定的编程语言,自有一套GraphQL schema language,与大多数的查询语言类似。

Object types and fields:对象类型和字段

对象类型是GraphQL用来表示该对象结构的对象,其包含查询的目标字段。

Query and Mutation types:

这两个是特殊的类型。每一个GraphQL必须有一个Query来指定查询处理。

Scalar types:默认标量类型

GraphQL对象类型有Int、Float、String、Boolean、ID这几种标量类型。

Enumeration types:枚举类型

枚举类型用来指定该类型的取值(可数的)。比如下列Nation类型只能取China、Japan、India这三个值。

enum Nation {  
  China
  Japan
  India
}

Lists:列表

GraphQL支持的数组类型。除了对象、标量、枚举类型这些类型外,还可以将字段定义为数组类型的数据,该字段能够内嵌包含标量的数组。

Interface types:接口类型

接口是一种抽象类型,可以指定实现接口时的类型字段。比如下列代码中的Character接口,和实现它的Human类型。Human类型除了实现接口必备的字段外,还有其特殊拥有的字段。

interface Character {  
  id: ID!
  name: String!
  friends: [Character]
  appearsIn: [Episode]!
}
type Human implements Character {  
  id: ID!
  name: String!
  friends: [Character]
  appearsIn: [Episode]!
  starships: [Starship]
  totalCredits: Int
}

正如我们上面所说,接口的查询需要借助内联片段来查询。

Union types:联合类型

联合类型与接口非常相似,不过其不需要指定公共字段。而是会把满足查询条件的所有union指定的数据组合在一个结果里。比如下列的SearchResult联合类型,就可以将不同类型(Hunam | Droid | Starship)的数据对象以一个结果数组返回给客户端。

union SearchResult = Human | Droid | Starship  

Input types:输入类型

除了传递标量数据,查询还可以传递复杂的对象。

input ReviewInput {  
  stars: Int!
  commentary: String
}

这样我们在mutation时就可以传递一个对象ReviewInput作为查询条件。

mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {  

Execution:执行

当被认可后,GraphQL查询就会被服务器执行并返回给客户端。GraphQL借助类型系统来执行查询,将每个字段当作函数或者上个类型的方法。而这类方法就叫做resolver。当执行到一个字段,相应的函数resolver也会被执行。而我们大多数的开发任务都将在这里完成。

resolver(obj, args, context)的三个参数分别表示:

  • obj: 前一个对象,root字段时这个参数为空
  • args: 查询条件参数
  • context: 上下文信息(比如用户信息、数据库链接)

如果resovler的执行是一种异步的方式(比如node中的数据库操作),GraphQL会等待Promises。

Introspection

该特性支持查询GraphQL Service提供查询的Schema信息。比如schema可以获得查询的数据结构,type可以获得字段的类型。

铺垫了这么多,下面开始动手编写GraphQL。首先,需要有一个支持ES6的node环境,然后搭建一个支持查询MongoDB数据库的Express with GraphQL。

Node with ES6

搭建Node环境版本为6.9.1,其可以通过--harmony参数运行带ES6特性的代码。但是Node不支持模块的导入导出(import)等特性,我们还是需要借助Babel库来将ES6的代码转换成兼容版本代码。

首先我们将必要的包安装好。

{
  "dependencies": {
    "bluebird": "^3.4.6",  //提供异步Promise的
    "body-parser": "^1.15.2",  //解析http请求主体
    "express": "^4.14.0",  //后端框架
    "express-graphql": "^0.6.1",  //封装上graphql的express
    "graphql": "^0.8.1",  //GraphQL的node实现
    "mongodb": "^2.2.11"  //数据库驱动
  },
  "devDependencies": {
    "babel-core": "^6.18.2",  //babel编译器
    "babel-polyfill": "^6.16.0",  //提供ES2015+的环境
    "babel-preset-es2015": "^6.18.0",  //提供所有2015包含的内容
    "babel-preset-node6": "^11.0.0",  //在node6.x的preset
    "babel-preset-stage-3": "^6.17.0",  //提供stage-3
    "babel-register": "^6.18.0"  //babel require的钩子
  }
}

上述 babel-preset-* 表示设定转码规则,我们需要在.babelrc中添加这些规则。

{
  "presets": [
    "es2015",
    "stage-3"
  ]
}

首先是入口文件,我们使用babel-register将后续的require改写成使用Babel进行转码。

//index.js
//require 'babel/register' to handle JavaScript code(successive 'require's will be babeled)
require('babel-register') //rewrite require cmd with Babel transform  
require('./server.js')  

在写后续的代码(server.js)就可以使用ES6的语法,首先是编写一个http服务器。

//server.js
import express from 'express';  
import schema from './schema.js';  
import { graphql} from 'graphql';  
import bodyParser from 'body-parser';  

第一步是使用import引用依赖模块。

//server.js
let app = express();  
let PORT = 2333;  
// parse post content as text
app.use(bodyParser.text({ type: 'application/graphql'}))  
app.use('/graphql', (req, res) => {  
  //GraphQL executor
  graphql(schema, req.body)
  .then((result) => {
  res.send(JSON.stringify(result, null, 2));
  })
});

然后就是配置一个GraphQL的Endpoint。将所有给/graphql路径的请求就交给GraphQL处理,并且请求的正文会被解析为'application/graphql'的文本。

let server = app.listen(PORT, function(){  
  let host = server.address().address;
  let port = server.address().port;
  console.log('GraphQL-api listening at http://%s:%s', host, port);
});

最后就是启动服务器。

而GraphQL处理请求的schema来自schema.js文件。schema.js中定义了一个简单的schema,其包含一个query操作和一个mutation操作。

// schema.js
import {  
  GraphQLObjectType,
  GraphQLSchema,
  GraphQLInt,
  GraphQLString
} from 'graphql';
// local variable to give client
let count = 0;  
// return RootQueryType Object { field: count }
let schema = new GraphQLSchema({  
  query: new GraphQLObjectType({
    name: 'RootQueryType',
    fields: {
      count: {
        type: GraphQLInt,
        description: 'Get count value',
        resolve: function(){
          return count;
        }
      }
    }
  }),
  // Note: Mutation is serialization of change data query
  mutation: new GraphQLObjectType({
    name: 'RootMutationType',
    fields: {
      updateCount: {
        type: GraphQLInt,
        description: 'Update the count',
        resolve: function(){
          count += 1;
          return count;
        }
      }
    }
  })
});
export default schema;  

我们打开命令行,敲入 curl -v -POST -H "Content-Type:application/graphql" -d 'query RootQueryType { count }' http://localhost:2333/graphql 就可以看到结果。

这样,我们就完成基本GraphQL Service。

Express-GraphQL

上述内容虽然能够完成GraphQL Server基本任务,但是对于调试不太友好。GraphiQL是官方推荐的调试工具,而express-graphql就集成了GraphiQL。所以我们用express-graphql重构下服务器代码。首先我们将schema.js移到data目录下方便管理代码。然后用graphqlHTTP替换成处理/graphql路由的函数。

graphqlHTTP接受的参数:schema就是数据对象的schema,graphiql控制GraphiQL(debug一般开启)的提供,pretty参数控制json响应的形式,rootValue用来传递在整个graphql共享的变量,formatError参数来指定处理错误的方式。

//server.js
import express from 'express';  
import query_schema from './data/schema.js';  
import graphqlHTTP from 'express-graphql';  
import bodyParser from 'body-parser';  
import { MongoClient } from 'mongodb';  
import Promise from 'bluebird';  
let app = express();  
let PORT = 2333;  
app.use(bodyParser.json({ type: 'application/json' }))  
app.use('/graphql', graphqlHTTP(req =>({  
  schema: query_schema,
  graphiql: true, // debug work
  pretty: true,
  rootValue: { db: req.app.locals.db }, // pass db(mongodb) to graphql
  formatError: error => ({ // return error
    message: error.message,
    locations: error.locations,
    stack: error.stack
  })
})));

在rootValue传递来一个express内置对象req的成员变量,在这个应用里是数据库连接客户端。这个客户端的定义如下。使用MongoClient连接本地数据库,第二个参数中的promiseLibrary用来指定异步处理的库,这里选用的是Bluebird的Promise对象。当app.locals.db的引用变量被指定为成功连接数据库的句柄后,就可以发布GraphQL service了。

MongoClient.connect('mongodb://localhost:27017/atm_analysis', { promiseLibrary: Promise })  
  .catch(err => console.error(err.stack))
  .then(db => {
    app.locals.db = db;
    let server = app.listen(PORT, function () {
      let host = server.address().address;
      let port = server.address().port;
      console.log('GraphQL-api listening at http://%s:%s', host, port); // ipv6 is :: 
    });
  });

接下来看看,schema应该怎么写。

首先是外层的GraphQL Schema对象,里头包含里一个查询。这个对象还内嵌了一个GraphQL Object类型的对象。对于这个内嵌对象,我们在resolve函数上进行数据库查询操作(node对于直接返回标量数据的resolver,会忽略resolver执行直接获得数据,这样可以加快响应速度)。

let NetnodeType = new GraphQLObjectType({  
  name: 'netnode',
  fields: {
    id: {
      type: GraphQLID
    },
    net_node_name: {
      type: GraphQLString
    },
    customer_name: {
      type: GraphQLString
    }
  }
});
// create instance of 'GraphQLSchema'
let schema = new GraphQLSchema({  
  query: new GraphQLObjectType({
    name: 'NetNodeInfo',  //object
    description: 'get netnode geograph infomation about fault, alarm',
    fields: {
      test: {
        type: GraphQLString,
        description: 'test info string',
        resolve: function () {
          return 'test graphql';
        }
      },
      node: {
        type: new GraphQLList(NetnodeType),
        description: 'netnode info',
        async resolve({ db }, args) {
          let data = await db.collection('dbo.TBL_NETNODE_INFO').find().limit(1500).sort({ 'ID': 1 }).toArray();
          return data.map(x => ({ id: x.ID, net_node_name: x.net_node_name, customer_name: x.customer_name }));
        }
      }
    }
  })
});

注意,这里一定要引入babel-polyfill库,不然会由于node没有完全支持async的相关特性,async函数的regenerator功能报错。

对于客户端的测试请求,我们可以先使用GraphiQL工具来操作。在浏览器敲入地址:http://localhost:2333/graphql

首先测试GraphQL的query,我们对NetNodeInfo的test字段进行查询。

test query

我们看到返回的data中有对应的数据,证明GraphQL Service正常运行。

然后测试对于数据库操作的字段,我们对NetNodeInfo的node字段进行查询。通过GraphiQL上右侧的自建文档可以看到,这个字段内部的对象有3个字段。下图的查询结果是只对"id"和"netnodename"字段查询的情况,返回的数据就不会包括没有请求的字段(没有"customer_name"字段)。

test node

GraphQL的这种灵活的接口能够降低对于复杂结构数据的请求数量,进而减少网络通信;而接口的自洽(自动生成接口文档)可以帮助前后端开发者的沟通,从而提高开发效率。


References:

  1. es6入门

  2. 深入浅出ES6

  3. ES6 Promises

  4. Reflect on MDN

  5. Reflection in Depth

  6. GraphQL浅析

  7. GraphQL官网资料

  8. Node.js 服务端实践之 GraphQL 初探