GraphQL 落地背后:利弊取舍

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

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

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

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

全貌

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

GraphQL 模型

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

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

仅仅有客户端是无法工作的。

初识

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

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

这里的服务端不仅指网络服务,用 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 {
        // return await this.items.find(recipe => recipe.title === title);
      }
    
      @Query(returns => [Recipe], { description: "Get all the recipes from around the world " })
      async recipes(): Promise {
        // 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 接口和前端业务是同一个人进行开发,对接口数据格式是熟知的,且接口调用方唯一、无复用,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 层,或者结合1、2点,将部分接口实现为 GraphQL,作为 BFF 层的一部分,其他接口还可以采取 RESTful 风格或其他风格,并不冲突。
  5. 当前端开发本身就要基于 Node.js 进行 BFF 层开发,团队对规范、文档有更高优先级的需求时,可以考虑使用 GraphQL 进行开发。

你可能感兴趣的:(graphql,typescript)