GraphQL 落地背后:利弊取舍

此文是做者考虑 GraphQL 在 Node.js 架构中的落地方案后所得。从最初考虑能够(之内置中间件)加入基础服务并提供完整的构建、发布、监控支持,到最终选择不改动基础服务以提供独立包适配,不限制实现技术选型,交由业务团队自由选择的轻量方式落地。中间经历了解除误解,对收益疑惑,对最初定位疑惑,最终完成利弊权衡的过程。前端

文章会从解除误解,技术选型,利弊权衡的角度,结合智联招聘的开发现状进行交流分享。git

文章会以 JavaScript 生态和 JavaScript 客户端调用与服务端开发体验为例。github

对入门知识不作详细阐述,可自行查阅学习指南中文(https://graphql.cn/learn/)/英文(https://graphql.org/learn/),规范中文(https://spec.graphql.cn/)/英文(https://github.com/graphql/graphql-spec/tree/master/spec),中文文档有些滞后,但不影响了解 GraphQL。ajax

全貌

GraphQL 是一种 API 规范。不是拿来即用的库或框架。不一样对 GraphQL 的实如今客户端的用法几乎没有区别,但在服务端的开发方式则天差地别。npm

GraphQL 模型

一套运行中的 GraphQL 分为三层:编程

  • 左侧是客户端和发出的 Document 和其余参数。
  • 中间是主要由 Schema 和 Resolver 组成的 GraphQL 引擎服务。
  • 右侧是 Resolver 对接的数据源。

仅仅有客户端是没法工做的。后端

初识

GraphQL 的实现能让客户端获取以结构化的方式,从服务端结构化定义的数据中只获取想要的部分的能力。缓存

符合 GraphQL 规范的实现我称之为 GraphQL 引擎。bash

这里的服务端不只指网络服务,用 GraphQL 做为中间层数据引擎提供本地数据的获取也是可行的,GraphQL 规范并无对数据源和获取方式加以限制。网络

  • 操做模型:GraphQL 规范中对数据的操做作了定义,有三种,query(查询)、mutation(变动)、subscription(订阅)。

客户端

咱们把客户端调用时发送的数据称为 Query Document(查询文档),是段结构化的字符串,形如:

# 客户端发送
query {
  contractedAuthor: {
    name
    articles {
      time
      title
    }
  }
  updateTime
}
# 或
mutation {
  # xxxxxx
}

须要注意的是 Query Document 名称中的 Query 和操做模型中的 query 是没有关系的,像上述示例所示,Query Document 也能够包含 mutation 操做。因此为了不误解,后文将把 Query Document(查询文档)称为 Document 或文档。一个 Document 中可包含单个或多个操做,每一个操做均可以查询补丁数量的跟字段。

其中 query 下的 updateTime、contractedAuthor 这种操做下的第一层字段又称之为 root field(根字段)。其余具体规范请自行查阅文档。

Schema

服务端使用名为 GraphQL Schema Language(或 Schema Definition LanguageSDL )的语言定义 Schema 来描述服务端数据。

# 服务端 schema
type Query {
  contractedAuthor: Author
  unContractedAuthor: Author
  updateTime: String
}

type Mutation{
  # xxx
}

type Subscription {
  # xxx
}

type Author {
  name: String
  articles: [Article]
}

type Article {
  time: String
  title: String
  content: String
}

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

能够看到,因为 GraphQL 是语言无关的,因此 SDL 带有本身简单的类型系统。具体与 JavaScript、Go 其余语言的类型如何结合,要看各语言的实现。

从上面的 Schema 中咱们能够获得以下的一个数据结构,这就是服务可提供的完整的数据的 Graph(图):

{
  query: {
    contractedAuthor: {
      name: String
      articles: [{
        time: String
        title: String
        content: String
      }]
    }
    unContractedAuthor: {
      name: String
      articles: [{
        time: String
        title: String
        content: String
      }]
    }
    updateTime: String
  }
  mutation: {
    # xxx
  }
  subscription: {
    # xxx
  }
}

在 Schema 定义中存在三种特殊的类型 Query、Mutation、Subscription,也称之为 root types(根类型),与 Document 中的操做模型一一对应的。

结合 Document 和 Schema,能够直观的感觉到 Document 和 Schema 结构的一致,且 Document 是 Schema 结构的一部分,那么数据就会按照 Document 的这部分返回,会获得以下的数据:

{
  errors: [],
  data: {
    contractedAuthor: {
      name: 'zpfe',
      articles: [
        {
          time: '2020-04-10',
          title: '深刻理解GraphQL'
        },
        {
          time: '2020-04-11',
          title: 'GraphQL深刻理解'
        }
      ]
    },
    updateTime: '2020-04-11'
  }
}

预期数据会返回在 data 中,当有错误时,会出现 errors 字段并按照规范规定的格式展现错误。

跑起来的 Schema

如今 Document 和 Schema 结构对应上了,那么数据如何来呢?

  • Selection Sets 选择集:

    query {
      contractedAuthor: {
        name
        articles {
          time
          title
        }
        honour {
          time
          name
        }
      }
      updateTime
    }

    如上的查询中存在如下选择集:

    # 顶层
    {
      contractedAuthor
      updateTime
    }
    # 二层
    {
      name
      articles
      honour
    }
    # articles:三层 1
    {
      time
      title
    }
    # honour:三层 2
    {
      time
      name
    }
  • Field 字段:类型中的每一个属性都是一个字段。

省略一些如校验、合并的细节,数据获取的过程以下:

  • 执行请求:GraphQL 引擎拿到 Document 并解析并处理以后,获得一个新的结构化的 Document(固然本来的 Document 也是结构化的,只不过是个字符串)。
  • 执行操做:引擎会首先分析客户端的目标操做,如是 query 时,则会去 Schema 中找到 Query 类型部分执行,由前文所说 Query、Mutation、Subscription 是特殊的操做类型,因此如 query、mutation、subscription 字段是不会出如今返回结果中的,返回结果中的第一层字段是前文提到的 root field(根字段)。
  • 执行选择集:此时已经明确的知道客户端但愿获取的 Selection Sets(选择集)。query 操做下,引擎通常会以广度优先、同层选择集并行执行获取选择集数据,规范没有明确规定。mutation 下,由于涉及到数据修改,规范规定要按照由上到下按顺序、深度优先的方式获取选择集数据。
  • 执行字段:

    • 肯定了选择集的执行顺序后开始真正的字段值的获取,很是简化的讲,Schema 中的类型应该对其每一个字段提供一个叫作 Resolver 的解析函数用于获取字段的值。那么可执行的 Schema 就形如:

      type Query {
        contractedAuthor () => Author
      }
      type Author {
        name () => String
        articles () => [Article]
      }
      type Article {
        time () => String
        title () => String
        content () => String
      }

      其中每一个类型方法都是一个 Resolver。

    • 在执行字段 Resolver 以后会得字段的值,若是值的类型为对象,则会继续执行其下层字段的 Resolver,如 contractedAuthor() 后获得值类型为 Author,会继续执行 name ()articles() 以获取 name 和 articles 的值,直到获得类型为标量(String、Int等)的值。
    • 同时虽然规范中没有规定 Resolver 缺乏的状况,但引擎实现时,通常会实现一个向父层字段(即字段所在对象)取与本身同名的属性的值的 Resolver。如未提供 Artical 对象 time 字段的 Resolver,则会直接取 artical.time。

至此由 Schema 和 Resolver 组合而成的可执行 Schema 就诞生了,Schema 跑了起来,GraphQl 引擎也就跑了起来。

GrahpQL 服务端开发的核心就是定义 Schema (结构)和实现相应的 Resolver(行为)

其余定义

固然,在使用 GraphQL 的过程当中,还能够:

  • 使用 Variables(变量)复用同一段 Document 来动态传参。
  • 使用 Fragments(片断)下降 Document 的复杂度。
  • 使用 Field Alias(字段别名)进行简单的返回结果字段重命名。

这些都没有什么问题。

可是在 Directives(指令)的支持和使用上,规范和实现是有冲突的。

  1. 规范内置指令:规范中只规定了 GraphQL 引擎须要实现 Document 中可用的 @skip(条件跳过)、@include(条件包含),在服务端 Schema 部分可用的 @deprecated(字段已废弃)指令。
  2. 自定义指令支持:在我查到的资料中,Facebook 与 graphql-js(Facebook提供实现)官方有不支持自定义指令的表态1(https://github.com/graphql/graphql-js/issues/446)2(https://github.com/graphql-rust/juniper/issues/156)3(https://github.com/graphql/graphql-js/issues/41)。在 Apollo 实现的 Graphql 生态中则是支持自定义 Schema 端可用的指令,对 Document 端的自定义指令实现暂不支持且不建议支持

而在研究 GraphQL 时发生的的误解在于:

  • 规范、教程提到 query(查询)时,没法确认是指客户端侧客户端发出的 Query Document 整个操做仍是,Document 中的 query 操做,亦或是服务端侧定义在 Schema 中的 Query 类型。
  • 或如讲到 Arguments、Variables 等概念,其原则、写法是位于三层的那部分。

实现与选型

GraphQL 的典型实现主要有如下几种:

  • graphql-js:由 Facebook 官方提供的实现。几乎是
  • Apollo GraphQL: Apollo 提供的实现和 GraphQL 生态,内容丰富,不止一套引擎,还提供了纯客户端使用(不局限JavaScript)多种工具。
  • type-graphql:强依赖 TypeScript 开发的实现,主要是输出可执行 Schema。

graphql-js 能够说是其余实现的基础。

可执行 Schema 的建立方式是这几种实现最大的不一样,下面将就这部分进行展现。

graphql-js

npm install --save graphql
  • 建立可执行 Schema

    import {
      graphql,
      GraphQLList,
      GraphQLSchema,
      GraphQLObjectType,
      GraphQLString,
    } from 'graphql'
    
    const article = new GraphQLObjectType({
      fields: {
        time: {
          type: GraphQLString,
          description: '写做时间',
          resolve (parentValue) {
            return parent.date
          }
        },
        title: {
          type: GraphQLString,
          description: '文章标题',
        }
      }
    })
    
    const author = new GraphQLObjectType({
      fields: {
        name: {
          type: GraphQLString,
          description: '做者姓名',
        },
        articles: {
          type: GraphQLList(article),
          description: '文章列表',
          resolve(parentValue, args, ctx, info) {
            // return ajax.get('xxxx', { query: args })
          },
        }
      },
    })
    
    const schema = new GraphQLSchema({
      query: new GraphQLObjectType({
        name: 'RootQuery',
        fields: {
          contractedAuthor: {
            type: author,
            description: '签约做者',
            resolve(parentValue, args, ctx, info) {
              // return ajax.get('xxxx', { query: args })
            },
          },
        },
      }),
    })

    能明确的看到,graphql-js 实现经过 GraphQLSchema 建立出的 schema 中,field 和 resolver 和他们一一对应的关系,同时此 schema 就是可执行 Schema。

  • 执行

    import { parse, execute, graphql } from 'graphql'
    import { schema } from '上面的schema'
    
    // 实际请求中,document 由 request.body 获取
    const document = `
    query {
      contractedAuthor {
        name
        articles {
          title
        }
      }
    }`
    // 或使用导入的 graphql 方法执行
    const response = await execute({
      schema,
      document: parse(document),
      // 其余变量参数等
    })

    传入可执行 schema 和解析后的 Document 便可获得预期数据。

Apollo

Apollo 提供了完整的 GraphQL Node.js 服务框架,可是为了更直观的感觉可执行 Schema 的建立过程,使用 Apollo 提供的 graphql-tools 进行可执行 Schema 建立。

npm install graphql-tools graphql

上面是 Apollo 给出的依赖安装命令,能够看到 graphql-tools 须要 graphql-js(graphql)做为依赖 。

  • 建立可执行 Schema

    import { makeExecutableSchema } from 'graphql-tools'
    
    const typeDefs = `
    type Article {
      time: String
      title: String
    }
    
    type Author {
      name: String
      articles: [Article]
    }
    
    type Query {
      contractedAuthor: Author
    }
    
    schema {
      query: Query
    }
    `
    const resolvers = {
      Query: {
        contractedAuthor (parentValue, args, ctx, info) {
          // return ajax.get('xxxx', { query: args })
        }
      },
      Author: {
        articles (parentValue, args, ctx, info) {
          // return ajax.get('xxxx', { query: args })
        }
      },
      Article: {
        time (article) {
          return article.date
        }
      }
    }
    const executableSchema = makeExecutableSchema({
      typeDefs,
      resolvers,
    })

    resolvers 部分以类型为维度,以对象方法的形式提供了 Resolver。在生成可执行 Schema 时,会将 Schema 和 Resolver 经过类型映射起来,有必定的理解成本。

type-graphql

这部分涉及 TypeScript,只作不完整的简要展现,详情自行查阅文档。

npm i graphql @types/graphql type-graphql reflect-metadata

能够看到 type-graphql 一样须要 graphql-js(graphql)做为依赖 。

  • 建立可执行 Schema

    import 'reflect-metadata'
    import { buildSchemaSync } from 'type-graphql'
    
    @ObjectType({ description: "Object representing cooking recipe" })
    class Recipe {
      @Field()
      title: string
    }
    
    @Resolver(of => Recipe)
    class RecipeResolver {
    
      @Query(returns => Recipe, { nullable: true })
      async recipe(@Arg("title") title: string): Promise<Recipe> {
        // return await this.items.find(recipe => recipe.title === title);
      }
    
      @Query(returns => [Recipe], { description: "Get all the recipes from around the world " })
      async recipes(): Promise<Recipe[]> {
        // return await this.items;
      }
    
      @FieldResolver()
      title(): string {
        return '标题'
      }
    }
    const schema = buildSchemaSync({
      resolvers: [RecipeResolver]
    })

    type-graphql 的核心是类,使用装饰器注解的方式复用类生成 Schema 结构,并由 reflect-metadata 将注解信息提取出来。如由 @ObjectType()@Field 将类 Recipe 映射为含有 title 字段的 schema Recipe 类型。由 @Query 注解将 reciperecipes 方法映射为 schema query 下的根字段。由 @Resolver(of => Recipe)@FieldResolver()title() 方法映射为类型 Recipe 的 title 字段的 Resolver。

关联与差别

同:在介绍 Apollo 和 type-graphql 时,跳过了执行部分的展现,是由于这两种实现生成的可执行 Schema 和 graphql-js 的是通用的,查看这二者最终生成的可执行 Schema 能够发现其类型定义都是使用的由 graphql-js 提供的 GraphQLObjectType 等, 能够选择使用 graphql-js 提供的执行函数(graphql、execute 函数),或 apollo-server 提供的服务执行。

异:

  • 结构:直接可见的是结构上的差别,graphql-js 做为官方实现提供告终构(Schema)和行为(Resolver)不分离的建立方式,没有直接使用 SDL 定义 Schema,好处是理解成本低,上手快;apollo 实现则使用结构和行为分离的方式定义,且使用了 SDL,结构和行为使用类名造成对应关系,有必定的理解成本,好处是 Schema 结构更直观,且使用 SDL 定义 Schema 更快。
  • 功能:

    • graphql-js:graphql-js 是绕不过的基础。提供了生成可执行 Schema 的函数和执行 Schema 生成返回值的函数(graphql、execute 函数),使用执行方法可快速将现有 API 接口快速改造为 GraphQL 接口。适合高度定制 GraphQL 服务或快速改造。
    • apollo:提供了开箱即用的完整的 Node.js 服务;提供了拼接 Schema(本地、远端)的方法,使 GraphQL 服务拆分红为可能;提供了客户端可用的数据获取管理工具。当遇到问题在 apollo 生态中找一找通常都会有收获。
    • type-grahpql:当使用 TypeScript 开发 GraphQL 时,通常要基于 TypeScript 对数据定义模型,也要在 Schema 中定义数据模型,此时 type-graphql 的类型复用的方式就比较适合。同时 type-grahpql 只纯粹的负责生成可执行 Schema,与其余服务实现不冲突,可是这个实现的稳定性还有待观察。

利弊

对 GraphQL 的直观印象就是按需、无冗余,这是显而易见的好处,那么在实际应用中真的这么直观美好么?

  • 声明式的获取数据:结构化的 Document 使得获得数据后,对数据的操做提供了必定便利(若是能打通服务端和客户端的类型公用,使得客户端在开发时提供代码智能提示更好)。
  • 调用合并:常常提到的与 RESTful 相比较优的一点是,当须要获取多个关联数据时,RESTful 接口每每须要屡次调用(并发或串行),而基于 GraphQL 的接口调用则能够将调用顺序体如今结构化的查询中,一次获取所有数据,减小了接口往返顺序。但同时也有一些注意事项,要真正减小调用次数,要在前端应用中集中定义好应用全局的数据结构,统一获取,若是仍然让业务组件就近获取(只让业务组件这种真正的使用方知晓数据结构),这个优点并不存在。
  • 无冗余:按需返回数据,在网络性能上确实有必定优化。
  • 文档化:GraphQL 的内省功能能够根据 Schema 生成实时更新的 API 文档,且没有维护成本,对于调用方直观且准确。
  • 数据 Mock:服务端 Schema 中包含数据结构和类型,因此在此基础上实现一个 Mock 服务并不困难,apollo-server 就有实现,能够加快前端开发介入。
  • 强类型(字段校验):因为 JS 语言特性,强类型只能称为字段强类型校验(包括入参类型和返回结果),当数据源返回了比 Schema 多或少的字段时,并不会引起错误,而就算采用了 TypeScript 因为没有运行时校验,也会有一样的问题。可是字段类型校验也会有必定的帮助。
  • 调试:因为咱们调用 GraphQL 接口时(如:xxx/graphql/im)没法像 RESTful 接口那样(如:xxx/graphql/im/messagexxx/graphql/im/user)从 URL 直接分辨出业务类型,会给故障排查带来一些不便。

上面提到的点几乎都是出于调用方的视角,能够看到,做为 GraphQL 服务的调用方是比较舒服的。

因为智联招聘前端架构Ada中包含基于 Node.js 的 BFF(Backends For Frontends 面向前端的后端)层,前端开发者有能力针对具体功能点开发一对一的接口,有且已经进行了数据聚合、处理、缓存工做,也在 BFF 层进行过数据模型定义的尝试,同时已经有团队在现有 BFF 中接入了 GraphQL 能力并稳定运行了一段时间。因此也会从 GraphQL 的开发者和二者间的角度谈谈成本和收益。

  • BFF:GraphQL 能够完成数据聚合、字段转换这种符合 BFF 特征的功能,提供了一种 BFF 的实现选择。
  • 版本控制:客户端结构化的查询方式可让服务追踪到字段的使用状况。且在增长字段时,根据结构化查询按需查询的特色,不会影响旧的调用(虽然 JavaScript 对多了个字段的事情不在乎)。对于服务的迭代维护有必定便利。
  • 开发成本:毫无疑问 Resolver(业务行为)的开发在哪一种服务模式下都不可缺乏,而 Schema 的定义必定是额外的开发成本,且主观感觉是 Schema 的开发过程仍是比较耗费精力的,数据结构复杂的状况下更为如此。同时考虑到开发人员的能力差别,GraphQL 的使用也会是团队长期的人员成本。像咱们在 BFF 层已经有了彻底针对功能点一对一的接口的状况下,接口一旦开发完成,后续迭代要么完全重写、要么再也不改动,这种状况下是用不到 GraphQL 的版本控制优点,将每一个接口都实现为 GraphQL 接口,收益不高。
  • 迁移改造:提供 GraphQL 接口有多种方式,能够彻底重写也能够定义 Schema 后在 Resolver 中调用现有接口,仅仅把 GraphQL 看成网关层。
  • 调用合并:GraphQL 的理念就是将多个查询合并,对应服务端,一般只会提供一个合并后的“大”的接口,那么本来以 URL 为粒度的性能监控、请求追踪就会有问题,可能须要改成以 root field(根字段)为粒度。这也是须要额外考虑的。
  • 文档化:在智联招聘所推行的开发模式中,一般 BFF 接口和前端业务是同一我的进行开发,对接口数据格式是熟知的,且接口调用方惟1、无复用,GraphQL 的文档化这一特性带来的收益也有限。
  • 规范:因为 GraphQL Schema 的存在,使得数据模型的定义成为了必要项。在使用 JavaScript 开发接口服务时,相对其余各类数据模型定义的尝试,提供了定义数据模型的统一实践和强规范,也算是收益之一。同时 Resolver 的存在强化了只在前端作 UI、交互而在 BFF 层处理逻辑的概念。

总结

综合来看,可用的 GraphQL 服务(不考虑拿 GraphQL 作本地数据管理的状况)的重心在服务提供方。做为 GraphQL 的调用方是很爽的,且几乎没有弊端。那么要不要上马 GraphQL 就要重点衡量服务端的成本收益了。就个人体会而言,有如下几种状况:

  1. 服务自己提供的就是针对具体功能的接口,接口只有单一的调用方,不存在想要获取的数据结构不固定的状况,或者说是一次性接口,发布完成后不用再迭代的,那么不必使用 GraphQL。
  2. 服务自己是基础服务,供多方调用,需求不一但对外有统一的输出模型的状况下(如:Github 开放接口,没法肯定每一个调用者需求是什么),可使用 GraphQL。
  3. 在 Node.js(JavaScript)中,因为面向对象、类型的支持程度问题,开发者编程思惟问题,实现成本比 Java 等其余语言更高,要谨慎考虑成本。
  4. 没有 BFF 层时,因为 GraphQL 对于实现数据聚合、字段转换提供了范式,能够考虑使用 GraphQL 服务做为 BFF 层,或者结合一、2点,将部分接口实现为 GraphQL,做为 BFF 层的一部分,其余接口还能够采起 RESTful 风格或其余风格,并不冲突。
  5. 当前端开发自己就要基于 Node.js 进行 BFF 层开发,团队对规范、文档有更高优先级的需求时,能够考虑使用 GraphQL 进行开发。
相关文章
相关标签/搜索