背景
7 月份我们前端团队推动落地了一个 toB 类型的系统, 由于服务端也由我们前端工程师来承接, 所以服务端技术选型上我们有了话语权, API 这一块儿我们选择了 GraphQL https://graphql.org/ . 本文将阐述我学习 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 https://graphql.org/code/#javascript , 服务端拿到客户端 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({
- // Name
- NAME: 'Name',
- // Document
- DOCUMENT: 'Document',
- OPERATION_DEFINITION: 'OperationDefinition',
- VARIABLE_DEFINITION: 'VariableDefinition',
- VARIABLE: 'Variable',
- // Values
- INT: '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 的 类型系统 https://graphql.org/graphql-js/type/ , 大家翻阅 API 文档即可.
技术边界
原理弄清楚之后我们需要对 GraphQL 这门技术的边界有一个清醒的认识:
客户端边界: 核心能力是将请求参数按照服务端定义好的 AST 语法树规范拼装成客户端 Schema 字符串, 实现方案大家可参考 apollo 提供的 webpack 插件 , 当然也有一些 GraphQL 客户端连发送 Ajax 请求的活儿也干了, 无非是在底层调用其他类库比如 axios 发请求.
服务端边界: 核心能力是识别客户端 Schema 字符串, 并通过服务端 Schema 调用底层的数据服务按需返回用户想要的数据, 至于底层数据源来自哪里(数据库或者三方接口), 以何种方式获取数据(直连数据库或者 ORM 方法调用), 这些不属于 GraphQL 关心的范畴.
问题解决的怎么样
由于 GraphQL 通过客户端 Schema 而不是通过 URL 描述数据述求, 所以理论上服务端只需要对客户端暴露一个地址即可, 解决了接口数量众多维护成本高的问题; 同时, 服务端提供的是全量字段, 客户端可按需获取, 面对接口扩展的需求, 服务端没有开发成本; 最后, 通过 GraphiQL 可视化调试界面展现服务端能提供的所有数据, 开发过程不再依赖接口文档:
GraphQL 社区在忙什么
GraphQL 官方提供核心能力:
graphql-js https://graphql.org/code/#javascript :GraphQL 理念的 JavaScript 实现, 该类库可同时运行在浏览器环境与 Node 环境, 该类库的原理我在上文中已经讲过了.
https://github.com/graphql/graphiql : 提升调试体验, 我在上文中提过.
https://github.com/facebook/dataloader : 提升性能, 通过合并请求尽量减少数据库查询次数.
Relay https://github.com/facebook/relay : 前端框架, 使 GraphQL 与 React 很好的融合在一起, 嵌入性较强, 需要 GraphQL Server 配合.
我们还缺什么?
服务端 官方只提供了 JavaScript 语言支持, 社区爱好者很快在不同编程语言中实现了 GraphQL 的理念: JAVA https://graphql.org/code/#java , .NET https://graphql.org/code/#c-net 等等, 更多语言支持, 请查看 官网 https://graphql.org/code/
客户端 官方提供的 Relay https://github.com/facebook/relay 解决了 GraphQL 与 React 相结合的问题, Apollo Client https://www.apollographql.com/client/ 提供了与其他前端框架融合的解决方案, 比如 vue,Angular 等等.
开发体验
graphql-tools : 在上文示例代码的服务端 Schema 中, 我们将类型的定义 (typeDefs) 与处理函数的定义 (resolvers) 放在同一个文件中, 职责上不够单一, 借助 https://github.com/apollographql/graphql-tools 我们可以将二者分不同的文件定义;
egg-graphql : 与 Node 框架 https://eggjs.org/zh-cn/index.html 相结合, 制定 目录规范 并提供语法糖提高开发效率;
总结
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!
- }
- }
- }
- }
- }
- }
- }
- }
复制代码
这样的查询语句会给数据库带来很大的性能开销, 服务端不得不做 限流 https://www.howtographql.com/advanced/4-security/ 来规避这样的问题, 这也带来了额外的开发成本.
侵入性: GraphQL 受益最大的是前端, 却需要服务端鼎力支持, 特别是老系统迁移, 服务端与前端都面临较大的改造.
学习成本: GraphQL 是一套全新的理念, 需要前后端同学都学习新的知识才能掌握这门技术, 这也带来较大的学习成本.
任何技术都有利弊, 大家要结合自己的场景权衡收益做出适合自己的技术选型.
参考文档
GraphQL 官网 https://graphql.org/
- Facebook GraphQL GitHub https://github.com/graphql
- graphql-js https://graphql.org/code/#javascript
- https://github.com/apollographql/graphql-tools
- Egg GithHub https://github.com/eggjs
- https://www.howtographql.com
文章可随意转载, 但请保留此 原文链接 https://www.yuque.com/es2049/blog . 非常欢迎有激情的你加入 ES2049 Studio https://es2049.studio/ , 简历请发送至 caijun.hcj(at) http://alibaba-inc.com .
来源: https://juejin.im/post/5b9b650df265da0afe62cf4e