
背景
7月份我们前端团队推动落地了一个 toB 类型的系统,由于服务端也由我们前端工程师来承接,所以服务端技术选型上我们有了话语权,API 这一块儿我们选择了 GraphQL 。本文将阐述我学习 GraphQL 这门技术的一些思考。
GraphQL 在解决什么问题
学习一门新技术,首先要把问题域弄清楚。社区有大量 GraphQL 与传统 API 解决方案(含 REST API)对比文章,总结下来,传统 API 存在以下问题:
接口数量众多维护成本高:接口的数量通常由业务场景的数量决定,为了尽量减少接口数量,服务端工程师通常会对业务做抽象,首先构建粒度较小的数据接口,再根据业务场景对数据接口进行组合,对外暴露业务接口,即便这样,服务端对前端暴露的接口数量还是非常多,因为业务总是多变的。
接口扩展成本高:出于带宽的考虑移动端我们要求接口返回尽量少的字段,PC 端通常要展现更多字段;考虑首屏性能,我们又要求对接口做合并;传统 API 应对这些需求,前后端都面临改造,成本较高。
接口响应的数据格式无法预知:由于接口文档几乎总是不能及时更新,前端工程师无法预知接口响应的数据格式,影响前端开发进度。
针对以上问题,GraphQL 给出了较为完善的解决方案。
GraphQL 如何解决问题
接下来我通过一个实例讲解 GraphQL 解决问题的思路,客户端的述求:根据性别查询团队成员列表,返回 id 、 gender 、 name 、 nickName ,GrahpQL 的处理过程如下图:
请求参数在发送到服务端之前会先经过 GraphQL Client 转换成客户端 Schema,这段 Schema 其实是一段 query 开头的字符串,描述了客户端的对数据的述求:调用哪个方法,传递什么样的参数,返回哪些字段。服务端拿到这段 Schema 之后,通过事先定义好的服务端 Schema 接收请求参数并执行对应的 resolve 函数提供数据服务。整个过程可以想象成我们吃自助餐的过程,服务端 Schema 就好比自助餐线,摆上我们能提供的所有食物;客户端 Schema 就描述了我们想要吃的食物,按需获取就好了。
讲到这里,好奇心强的同学可能已经开始思考这个问题了:客户端 Schema 本质上就是一段字符串,服务端如何识别并响应这段字符串?
graphql-js
识别与响应客户端 Schema 依赖于官方类库 graphql-js ,服务端拿到客户端 Schema 字符串后会做如下处理:
- 解析阶段
为了识别客户端 Schema,graphql-js定义了一系列的特征标识符:
export const TokenKind = Object.freeze({BANG: '!',DOLLAR: '$',PAREN_L: '(',PAREN_R: ')',SPREAD: '...',COLON: ':',EQUALS: '=',BRACKET_L: '[',BRACKET_R: ']',...});
并定义了 AST 语法树规范,规定语法树支持以下节点:
/*** The set of allowed kind values for AST nodes.*/export const Kind = Object.freeze({// NameNAME: 'Name',// DocumentDOCUMENT: 'Document',OPERATION_DEFINITION: 'OperationDefinition',VARIABLE_DEFINITION: 'VariableDefinition',VARIABLE: 'Variable',// ValuesINT: 'IntValue',FLOAT: 'FloatValue',STRING: 'StringValue',BOOLEAN: 'BooleanValue',...});
有了特征字符串与 AST 语法树规范,GraphQL Server 对客户端 Schema 进行逐字符扫描(charCodeAt),最终解析阶段的产出物为 document ,上文示例中的客户端 Schema 解析完成之后的部分 document :
{"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DisplayMember","loc":{"start":13,"end":26}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":null,"name":{"kind":"Name","value":"fetchByGender","loc":{"start":37,"end":50}},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"gender","loc":{"start":51,"end":57}},"value":{"kind":"StringValue","value":"M","loc":{"start":59,"end":62}},"loc":{"start":51,"end":62}}],...
如果客户端 Schema 不符合服务端定义的 AST 规范,解析过程会直接抛出语法异常 Syntax Error ,拿上文的示例举例,我将客户端 Schema 中的 fetchByGender(gender: "M") 改为 fetchByGender(gender) ,只传递参数名,不传递参数值,则服务端会响应:
{"errors":[{"message":"Syntax Error GraphQL request (3:29) Expected :, found )2: query DisplayMember {3: fetchByGender(gender) {^4: list {","locations":[{"line":3,"column":29}]}]}
结构化的报错信息也是 GraphQL 的一大特点,定位问题非常方便。只要语法没问题解析阶段就能顺利完成,然后进入校验阶段。
- 校验阶段
校验阶段用于验证客户端 Schema 是否按照服务端 Schema 定义好的方式获取数据,比如:获取数据的方法名是否有误,必填项是否有值等等,校验范围一共有几十种,我没有办法一一举例。拿上文的示例举例,我将客户端 Schema 中的 fetchByGender 改为 fetchByGen , fetchByGen 在服务端根本没有定义,则服务端会响应:
{"errors":[{"message":"Cannot query field "fetchByGen" on type "Query". Did you mean "fetchByGender"?","locations":[{"line":3,"column":9}]}]}
不仅返回结构化的报错信息,还非常人性化的告诉你正确的调用方式是什么。校验阶段通过之后会进入执行阶段
- 执行阶段
执行阶段依赖的输入为:解析阶段的产出物 document ,服务端 Schema;其中 document 准确描述了客户端对数据的述求:请求哪个方法,参数是什么,需要哪些字段;服务端 Schema 描述了提供数据的方式;拿上文的示例举例,服务端 Schema 需要这样定义:
const graphqlApi = require('graphql');const {GraphQLObjectType,GraphQLList,GraphQLNonNull,GraphQLSchema,GraphQLString,} = graphqlApi;const dataSource = require('./dataSource');const memType = new GraphQLObjectType({name: 'Male',description: 'A member gender is Male.',fields: () => ({id: {type: new GraphQLNonNull(GraphQLString),description: 'The id of member',},name: {type: GraphQLString,description: 'The name of the character.',},nickName: {type: GraphQLString,description: 'The nickName of the character.',},gender: {type: GraphQLString,description: 'The gender of the character.',},list: {type: new GraphQLList(memType),description: 'The mems list by gender.',},})});const queryType = new GraphQLObjectType({name: 'Query',fields: () => ({fetchByGender: {type: memType,args: {gender: {description: 'gender of the human',type: new GraphQLNonNull(GraphQLString),},},resolve: (root, { gender }) => {// 访问数据库或三方 API 查询成员列表return {list: dataSource.getMembers(gender),};},},}),});module.exports = new GraphQLSchema({query: queryType,types: [memType],});
执行服务端 Schema 中的 resolve 函数,得到执行阶段的输出:
{"data":{"fetchByGender":{"list":[{"id":"1","gender":"M","name":"童开宏","nickName":"慕冥"}]}}}
当然要完成服务端 Schema 的定义,你需要学习 GraphQL 的 类型系统 ,大家翻阅 API 文档即可。
技术边界
原理弄清楚之后我们需要对 GraphQL 这门技术的边界有一个清醒的认识:
客户端边界:核心能力是将请求参数按照服务端定义好的 AST 语法树规范拼装成客户端 Schema 字符串,实现方案大家可参考
apollo提供的 Webpack 插件 ,当然也有一些 GraphQL 客户端连发送 Ajax 请求的活儿也干了,无非是在底层调用其他类库比如axios发请求。服务端边界:核心能力是识别客户端 Schema 字符串,并通过服务端 Schema 调用底层的数据服务按需返回用户想要的数据,至于底层数据源来自哪里(数据库或者三方接口),以何种方式获取数据(直连数据库或者 ORM 方法调用),这些不属于 GraphQL 关心的范畴。
问题解决的怎么样
由于 GraphQL 通过客户端 Schema 而不是通过 URL 描述数据述求,所以理论上服务端只需要对客户端暴露一个地址即可,解决了接口数量众多维护成本高的问题;同时,服务端提供的是全量字段,客户端可按需获取,面对接口扩展的需求,服务端没有开发成本;最后,通过 GraphiQL 可视化调试界面展现服务端能提供的所有数据,开发过程不再依赖接口文档:
GraphQL 社区在忙什么
GraphQL 官方提供核心能力:
graphql-js :GraphQL 理念的 JavaScript 实现,该类库可同时运行在浏览器环境与 Node 环境,该类库的原理我在上文中已经讲过了。
graphiql :提升调试体验,我在上文中提过。
dataloader :提升性能,通过合并请求尽量减少数据库查询次数。
Relay :前端框架,使 GraphQL 与 React 很好的融合在一起,嵌入性较强,需要 GraphQL Server 配合。
我们还缺什么?
服务端
官方只提供了 JavaScript 语言支持,社区爱好者很快在不同编程语言中实现了 GraphQL 的理念:JAVA 、 .NET 等等,更多语言支持,请查看 官网客户端
官方提供的 Relay 解决了 GraphQL 与 React 相结合的问题,Apollo Client 提供了与其他前端框架融合的解决方案,比如 Vue、Angular 等等。开发体验
graphql-tools:在上文示例代码的服务端 Schema 中,我们将类型的定义(typeDefs)与处理函数的定义(resolvers)放在同一个文件中,职责上不够单一,借助 graphql-tools 我们可以将二者分不同的文件定义;
总结
GraphQL 的优点上文已经讲过了,真的是从业务痛点出发,解决了传统 API 存在的问题,但是 GraphQL 在解决问题的同时也带了一些新的问题,这些问题在某种程度上阻碍了这门技术的普及:
- 数据库性能:GraphQL 将数据描述成一张巨大的网,理论上客户端 Schema 可以写出任意嵌套层级的查询语句,比如:
query IAmEvil {author(id: "abc") {posts {author {posts {author {posts {author {# that could go on as deep as the client wants!}}}}}}}}
这样的查询语句会给数据库带来很大的性能开销,服务端不得不做 限流 来规避这样的问题,这也带来了额外的开发成本。
侵入性:GraphQL 受益最大的是前端,却需要服务端鼎力支持,特别是老系统迁移,服务端与前端都面临较大的改造。
学习成本:GraphQL 是一套全新的理念,需要前后端同学都学习新的知识才能掌握这门技术,这也带来较大的学习成本。
任何技术都有利弊,大家要结合自己的场景权衡收益做出适合自己的技术选型。
参考文档
GraphQL 官网
Facebook GraphQL GitHub
graphql-js
graphql-tools
Egg GithHub
howtographql
文章可随意转载,但请保留此 原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj(at)alibaba-inc.com 。
