Jade Dungeon

GraphQL

概述

现代软件工程发展让前端界面与后端服务有了明显的分工, 经常会遇到前端向后端索取数据时,后端要开发对应的接口。

GraphQL本质上是一套规范,作用是中前端界面与后端服务之间建立一个中间层。 把后端多个接口可提供的数据按GraphQL规范整合起来,组装成前端的需要的数据格式。 避免了后台接口频繁随着前端需求改变的情况。

GraphQL的形式类似于SQL:按指定的字段与条件,查询出所需类型的记录。

语法上分为两部分:

  • 用来查询与修改数据的「操作语法」(Operation)包含:
    • 查询(query)
    • 修改(mutation)
    • 订阅(subscription)
  • 用来定义数据结构的「定义语法」(Schema)

schema 中大部分的类型都是普通对象类型,但是一个 schema 内有两个特殊类型, 用来定义每一个 GraphQL 查询的入口:

  • query 类型,每一个 GraphQL 服务都有一个。
  • mutation 类型,可能有也可能没有。
schema {
  query: Query
  mutation: Mutation
}

例如,GraphQL 服务需要一个Query类型,且其上有herodroid字段:

type Query {
  hero(episode: Episode): Character
  droid(id: ID!): Droid
}

这样以后才能用查询操作来查询 herodroid

query {
  hero { name }
  droid(id: "2000") { name }
}

返回:

{
  "data": {
    "hero" : { "name": "R2-D2" },
    "droid": { "name": "C-3PO" }
  }
}

变更也是类似的工作方式 —— 你在Mutation类型上定义一些字段, 然后这些字段将作为mutation根字段使用,接着你就能在你的查询中调用。

有必要记住的是,除了作为schema的入口, QueryMutation类型与其它 GraphQL 对象类型别无二致, 它们的字段也是一样的工作方式。

数据类型

标量类型(Scalar Types)

基本类型:

GraphQL 自带一组默认标量类型:

  • Int:有符号 32 位整数。
  • Float:有符号双精度浮点值。
  • String:UTF‐8 字符序列。
  • Boolean:true 或者 false。
  • ID:ID 标量类型表示一个唯一标识符,通常用以重新获取对象或者作为缓存中的键。 ID 类型使用和String一样的方式序列化;然而将其定义为ID意味着并不需要人类可读型。

自定义标量

大部分的 GraphQL 服务实现中,都有自定义标量类型的方式。 例如,我们可以定义一个Date类型:

scalar Date

然后就取决于我们的实现中如何定义将其序列化、反序列化和验证。例如, 你可以指定Date类型应该总是被序列化成整型时间戳, 而客户端应该知道去要求任何date字段都是这个格式。

枚举类型(Enumeration Types)

也称作枚举(enum),枚举类型是一种特殊的标量,它限制在一个特殊的可选值集合内。

例,定义一个枚举:

enum Episode {
  NEWHOPE
  EMPIRE
  JEDI
}

基础应用

GraphQL把所有数据按图形组织到一个图中:

  • 整个图(即所有的数据)有一个最根本的起点:Query
  • 具体的数据都挂在Query节点下。
type Query {

}

像数据库一样定义类型,Book

  • Query是所有数据的起点,所以要把Book也挂到Query上作为属性。
  • Book的其中一个属性是Author
  • Book类型可能有多条记录,要当作Query属性的话就要按唯一ID指定。
type Query {
  book: Book     # id指定唯一的Book
}

type Book {
  id: ID
  name: String
  pageCount: Int
}

查询操作

查询通过关键字query指定,一般可以省略。 但是加上关键字就更加容易追踪、调试我们的代码,并在其被调用的时候做日志。

在查询中指定返回类型中的哪些字段,用空格或换行分隔。

query {
	book { name }
} 

返回:

{
	"book": {
		"name": "Harry Potter and the Philosopher's Stone",
	}
}

字段值参数

参数相当于查询条件,如果没有参数的话,一般只返回第一条记录:

Schema定义:

type Query {
  book: Book 
}

type Book {
  id: ID
  name: String
  pageCount: Int
}

查询:

query {
	book { id name pageCount }
} 

返回:

{
	"book": {
		"id": "book-1",
		"name": "Harry Potter and the Philosopher's Stone",
		"pageCount": 223
	}
}

通过参数给字段的值,格式为类型 (字段: 值),返回的就是字段值匹配的记录。

Schema:

type Query {
  bookById(id: ID): Book        # 限制需要ID
}

type Book {
  id: ID
  name: String
  pageCount: Int
}

查询:

query {
	bookById(id: "book-1") {    # 指定ID
		id name pageCount
	}
} 

返回:

{
	"book": {
		"id": "book-1",
		"name": "Harry Potter and the Philosopher's Stone",
		"pageCount": 223
	}
}

类型字段

字段可能有多种类型,比如是另一种类型的数据,比如给书籍增加作者类型Author

Schema定义:

type Query {
  bookById(id: ID): Book 
}

type Book {
  id: ID
  name: String
  pageCount: Int
  author: Author
}

type Author {
  id: ID
  firstName: String
  lastName: String
}

查询:

query {
	bookById(id: "book-1") {
		id
		name
		pageCount
		author { firstName lastName }
	}
} 

返回:

{
	"bookById": {
		"id": "book-1",
		"name": "Harry Potter and the Philosopher's Stone",
		"pageCount": 223,
		"author": {
			"firstName": "Joanne",
			"lastName": "Rowling"
		}
	}
}

如果是另一种类型的记录,返回的是数组:

{
  "data": {
    "hero": {
      "name": "R2-D2",
      "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"],
      "friends": [
        {"name": "Luke Skywalker"},
        {"name": "Han Solo"},
        {"name": "Leia Organa"}
      ]
    }
  }
}

非空约束

约束字段非空

例子,定义一个新的类型「角色」:

type Character {
  name: String!
  appearsIn: [Episode!]!
}

约束数组:

约束数组本身可以为空,但是其不能有任何空值成员:

myField: [String!]
myField: null // 有效
myField: [] // 有效
myField: ['a', 'b'] // 有效
myField: ['a', null, 'b'] // 错误

约束数组本身不能为空,但是其可以包含空值成员:

myField: [String]!
myField: null // 错误
myField: [] // 有效
myField: ['a', 'b'] // 有效
myField: ['a', null, 'b'] // 有效

非空约束还可以嵌套,例如数组与成员都不能为空:

myField: [String!]!
myField: null // 错误
myField: [] // 有效
myField: ['a', 'b'] // 有效
myField: ['a', null, 'b'] // 错误

字段标量(scalar)参数

GraphQL 对象类型上的每一个字段都可能有零个或者多个参数,例如下面的length字段:

type Starship {
  id: ID!
  name: String!
  length(unit: LengthUnit = METER): Float
}

标题可以指定值的转换显示,如不同的单位(公制和英制):

{
  human(id: "1000") {
    name
    height(unit: FOOT)
  }
}

返回:

{
  "data": {
    "human": {
      "name": "Luke Skywalker",
      "height": 5.6430448
    }
  }
}

字段别名

如果有查询两个同名的字段,会报错:

{                  # 错误,`root`根节点下,两个同名为`hero`的字段
  hero(episode: EMPIRE) { name }
  hero(episode: JEDI)   { name }
}

通过别名 : 字段的形式,指定两个不同的别名:

{
  empireHero: hero(episode: EMPIRE) { name }
  jediHero:   hero(episode: JEDI)   { name }
}

返回:

{
  "data": {
    "empireHero": { "name": "Luke Skywalker" },
    "jediHero"  : { "name": "R2-D2" }
  }
}

片段(Fragments)

定义片段:

片段用来提取出重复的代码块:

  • 定义片段:fragment 片段名 on 类型
  • 引用片段:... 片段名
query {
  leftComparison: hero(episode: EMPIRE) { ... comparisonFields }
  rightComparison: hero(episode: JEDI)  { ... comparisonFields }
}

fragment comparisonFields on Character {
  name
  appearsIn
  friends { name }
}

返回:

{
  "data": {
    "leftComparison": {
      "name": "Luke Skywalker",
      "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"],
      "friends": [
        { "name": "Han Solo" },
        { "name": "Leia Organa" },
        { "name": "C-3PO" },
        { "name": "R2-D2" }
      ]
    },
    "rightComparison": {
      "name": "R2-D2",
      "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"],
      "friends": [
        { "name": "Luke Skywalker" },
        { "name": "Han Solo" },
        { "name": "Leia Organa" }
      ]
    }
  }
}

片段中使用变量

  • 查询中声明变量:$变量名:变量类型=默认值
  • 片段中访问量变:$变量名
  • 查询时附加一个JSON指定变量的值。
query HeroComparison($first: Int = 3) { # 定义数据类型变量,默认值为3
  leftComparison: hero(episode: EMPIRE) { ...comparisonFields }
  rightComparison: hero(episode: JEDI)  { ...comparisonFields }
}

fragment comparisonFields on Character {
  name
  friendsConnection(first: $first) {
    totalCount
    edges {
      node { name }
    }
  }
}

指定变量的值:

{"first": 21}

返回:

{
  "data": {
    "leftComparison": {
      "name": "Luke Skywalker",
      "friendsConnection": {
        "totalCount": 4,
        "edges": [
          {"node": {"name": "Han Solo"}},
          {"node": {"name": "Leia Organa"}},
          {"node": {"name": "C-3PO"}},
          {"node": {"name": "R2-D2"}}
        ]
      }
    },
    "rightComparison": {
      "name": "R2-D2",
      "friendsConnection": {
        "totalCount": 3,
        "edges": [
          {"node": {"name": "Luke Skywalker"}},
          {"node": {"name": "Han Solo"}},
          {"node": {"name": "Leia Organa"}}
        ]
      }
    }
  }
}

约束变量非空:

!(感叹号)还可以约束变量为非空:

query DroidById($id: ID!) {
  droid(id: $id) { name }
}

变量:

{ "id": null }

返回:

{
  "errors": [
    {
      "message": "Variable \"$id\" of required type \"ID!\" was not provided.",
      "locations": [{"line": 1, "column": 17}]
    }
  ]
}

指令(Directives)

指令可以附着在字段或者片段包含的字段上, 然后以任何服务端期待的方式来改变查询的执行。

GraphQL 的核心规范包含两个指令,其必须被任何规范兼容的 GraphQL 服务器实现所支持:

  • @include(if: Boolean)仅在参数为 true 时,包含此字段。
  • @skip(if: Boolean)如果参数为 true,跳过此字段。

include指令

query Hero($episode: Episode, $withFriends: Boolean!) {
  hero(episode: $episode) {
    name
    friends @include(if: $withFriends) {
      name
    }
  }
}

指定变量:

{"episode": "JEDI", "withFriends": false}

返回:

{
  "data": {
    "hero": {"name": "R2-D2"}
  }
}

skip指令

query Hero($episode: Episode, $withFriends: Boolean!) {
  hero(episode: $episode) {
    name
    friends @skip(if: $withFriends) {
      name
    }
  }
}

指定变量:

{"episode": "JEDI", "withFriends": false}

返回:

{
  "data": {
    "hero": {
      "name": "R2-D2",
      "friends": [
        {"name": "Luke Skywalker"},
        {"name": "Han Solo"},
        {"name": "Leia Organa"}
      ]
    }
  }
}

接口与内联类型

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
}

type Droid implements Character {
  id: ID!
  name: String!
  friends: [Character]
  appearsIn: [Episode]!
  primaryFunction: String
}

当你要返回一个对象或者一组对象,特别是一组不同的类型时,接口就显得特别有用:

query HeroForEpisode($ep: Episode!) {
  hero(episode: $ep) { name }
}
{"ep": "JEDI"}

返回:

{
  "data": {
    "hero": { "name": "R2-D2" }
  }
}

如果要返回子类中的特别字段,可以参考「内联片段」部分的说明。

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

返回:

{
  "data": {
    "hero": {
      "name": "R2-D2",
      "primaryFunction": "Astromech"
    }
  }
}

联合类型(Union Types)

注意,联合类型的成员需要是具体对象类型; 不能使用接口或者其他联合类型来创造一个联合类型。

例:

union SearchResult = Human | Droid | Starship

任何返回一个SearchResult类型的地方, 都可能得到一个HumanDroid或者Starship

如果需要查询一个返回SearchResult联合类型的字段, 那么你得使用条件片段才能查询任意字段:

{
  search(text: "an") {
    __typename
    ... on Human    { name height }
    ... on Droid    { name primaryFunction }
    ... on Starship { name length }
  }
}

返回,_typename字段解析为String,它允许你在客户端区分不同的数据类型:

{
  "data": {
    "search": [
      {
        "__typename": "Human",
        "name": "Han Solo",
        "height": 1.8
      },
      {
        "__typename": "Human",
        "name": "Leia Organa",
        "height": 1.5
      },
      {
        "__typename": "Starship",
        "name": "TIE Advanced x1",
        "length": 9.2
      }
    ]
  }
}

此外,在这种情况下,由于HumanDroid共享一个公共接口(Character), 你可以在一个地方查询它们的公共字段,而不必在多个类型中重复相同的字段:

{
  search(text: "an") {
    __typename
    ... on Character { name }
    ... on Human     { height }
    ... on Droid     { primaryFunction }
    ... on Starship  { name length }
  }
}

注意name仍然需要指定在Starship上,否则它不会出现在结果中, 因为Starship并不是一个Character

内联片段(Inline Fragments)

如果查询的字段返回的是接口或者联合类型, 那么可能需要使用内联片段来取出下层具体类型的数据:

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

hero字段的类型根据episode参数值不同,其可能是Human或者Droid

如果要请求具体类型上的字段,你需要使用一个类型条件内联片段。

  • 第一个片段标注为... on DroidprimaryFunction仅在hero返回的 CharacterDroid类型时才会执行。
  • 同理适用于Human类型的height字段。

具体变量的值:

{ "ep": "JEDI" }

返回:

{
  "data": {
    "hero": {
      "name": "R2-D2",
      "primaryFunction": "Astromech"
    }
  }
}

换一个值:

{ "ep": "EMPIRE" }

返回:

{
  "data": {
    "hero": {
      "name": "Luke Skywalker",
      "height": 1.72
    }
  }
}

元字段(Meta fields)

在不知道从 GraphQL 服务获得什么类型的情况下,就需要元数据来决定如何处理这些数据。

GraphQL 允许在查询的任何位置请求__typename,一个元字段, 以获得那个位置的对象类型名称:

{
  search(text: "an") {
    __typename
    ... on Human    { name }
    ... on Droid    { name }
    ... on Starship { name }
  }
}

返回:

{
  "data": {
    "search": [
      { "__typename": "Human",    "name": "Han Solo" },
      { "__typename": "Human",    "name": "Leia Organa" },
      { "__typename": "Starship", "name": "TIE Advanced x1" }
    ]
  }
}

上面的查询中,search返回了一个联合类型,其可能是三种选项之一。 没有__typename字段的情况下,几乎不可能在客户端分辨开这三个不同的类型。

GraphQL 服务提供了不少元字段,剩下的部分用于描述「内省」系统。

变更(Mutations)

REST 中,任何请求都可能最后导致一些服务端副作用, 但是约定上建议不要使用GET请求来修改数据。 GraphQL 也是类似 —— 技术上而言,任何查询都可以被实现为导致数据写入。 然而,建一个约定来规范任何导致写入的操作都应该显式通过变更(mutation)来发送。

mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
  createReview(episode: $ep, review: $review) {
    stars
    commentary
  }
}

这个例子中,我们传递的review变量并非标量。它是一个「输入对象类型」。 这种特殊的对象类型可以作为参数传递。这在定义后台数据结构的Schema语法上再解释。

参数:

{
  "ep": "JEDI",
  "review": {
    "stars": 5,
    "commentary": "This is a great movie!"
  }
}

返回:

{
  "data": {
    "createReview": {
      "stars": 5,
      "commentary": "This is a great movie!"
    }
  }
}

输入类型(Input Types)

输入类型传递复杂对象。这在变更(mutation)中特别有用, 因为有时候你需要传递一整个对象作为新建对象。

输入对象看上去和常规对象一模一样,除了关键字是input而不是type

input ReviewInput {
  stars: Int!
  commentary: String
}

你可以像这样在变更(mutation)中使用输入对象类型:

mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
  createReview(episode: $ep, review: $review) {
    stars
    commentary
  }
}
{
  "ep": "JEDI",
  "review": {
    "stars": 5,
    "commentary": "This is a great movie!"
  }
}

返回:

{
  "data": {
    "createReview": {
      "stars": 5,
      "commentary": "This is a great movie!"
    }
  }
}

输入对象类型上的字段本身也可以指代输入对象类型, 但是你不能在你的 schema 混淆输入和输出类型。 输入对象类型的字段当然也不能拥有参数。

变更中的多个字段(Multiple fields in mutations)

一个变更也能包含多个字段,一如查询。查询和变更之间名称之外的一个重要区别是:

  • 查询字段时,是并行执行,
  • 变更字段时,是线性执行,一个接着一个。

这意味着如果我们一个请求中发送了两个incrementCredits变更, 第一个保证在第二个之前执行,以确保不会出现竞态。

执行机制

可以将 GraphQL 查询中的每个字段视为返回子类型的父类型函数或方法。 事实上,这正是 GraphQL 的工作原理。每个类型的每个字段都由一个 resolver 函数支持, 该函数由 GraphQL 服务器开发人员提供。当一个字段被执行时, 相应的 resolver 被调用以产生下一个值。

如果字段产生标量值,例如字符串或数字,则执行完成。如果一个字段产生一个对象, 则该查询将继续执行该对象对应字段的解析器,直到生成标量值。 GraphQL 查询始终以标量值结束。

{
  human(id: 1002) {
    name
    appearsIn
    starships { name }
  }
}

返回:

{
  "data": {
    "human": {
      "name": "Han Solo",
      "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"],
      "starships": [
        {"name": "Millenium Falcon"},
        {"name": "Imperial shuttle"}
      ]
    }
  }
}

根字段 & 解析器

每一个 GraphQL 服务端应用的顶层, 必有一个类型代表着所有进入 GraphQL API 可能的入口点, 我们将它称之为 Root 类型或 Query 类型。

以一javascript实现的GraphQL为例:

Query: {
  human(obj, args, context, info) {
    return context.db.loadHumanByID(args.id).then(
      userData => new Human(userData)
    )
  }
}

在这个例子中查询类型提供了一个字段human,并且接受一个参数 id。 这个字段的解析器可能请求了数据库之后通过构造函数返回一个Human对象。

解析器函数接收 4 个参数:

  • obj:上一级对象,如果字段属于根节点查询类型通常不会被使用。
  • args:可以提供在 GraphQL 查询中传入的参数。
  • context:会被提供给所有解析器, 并且持有重要的上下文信息比如当前登入的用户或者数据库访问对象。
  • info:一个保存与当前查询相关的字段特定信息以及 schema 详细信息的值, 更多详情请参考类型GraphQLResolveInfo.

异步解析器

让我们来分析一下在这个解析器函数中发生了什么。

human(obj, args, context, info) {
  return context.db.loadHumanByID(args.id).then(
    userData => new Human(userData)
  )
}

context提供了一个数据库访问对象,用来通过查询中传递的参数id来查询数据, 因为从数据库拉取数据的过程是一个异步操作,该方法返回了一个 Promise 对象,

这里要注意的是,只有解析器能感知到 Promise 的进度, GraphQL 查询只关注一个包含着name属性的human字段是否返回, 在执行期间如果异步操作没有完成,则 GraphQL 会一直等待下去, 因此在这个环节需要关注异步处理上的优化。

不重要的解析器

现在Human对象已经生成了,但 GraphQL 还是会继续递归执行下去:

Human: {
  name(obj, args, context, info) {
    return obj.name
  }
}

GraphQL 服务端应用的业务取决于类型系统的结构。在human对象返回值之前, 由于类型系统确定了human字段将返回一个Human对象, GraphQL 会根据类型系统预设好的Human类型决定如何解析字段。

在这个例子中,对name字段的处理非常的清晰,name字段对应的解析器被调用的时候, 解析器回调函数的obj参数是由上层回调函数生成的new Human对象。在这个案例中, 我们希望Human对象会拥有一个name属性可以让我们直接读取。

事实上,许多 GraphQL 库可以让你省略这些简单的解析器, 假定一个字段没有提供解析器时, 那么应该从上层返回对象中读取和返回和这个字段同名的属性。

标量强制

name字段被处理后,appearsInstarships字段可以被同步执行, appearsIn字段也可以有一个简单的解析器:

Human: {
  appearsIn(obj) {
    return obj.appearsIn // returns [ 4, 5, 6 ]
  }
}

类型系统声明appearsIn字段将返回具有已知值的枚举值,但是此函数返回数字!

实际上,如果我们查看结果,我们将看到正在返回适当的枚举值。这是怎么回事?

这是一个强制标量的例子。因为类型系统已经被设定, 所以解析器函数的返回值必须符合与类型系统对应的 API 规则的约束。

在这个案例中,我们可能在服务器上定义了一个枚举类型, 它在内部使用像是 4、5 和 6 这样的数字, 但在 GraphQL 类型系统中将它们表示为枚举值。

列表解析器

我们已经看到一个字段返回上面的appearsIn字段的事物列表时会发生什么。 它返回了枚举值的列表,因为这是系统期望的类型, 列表中的每个项目被强制为适当的枚举值。 让我们看下startships被解析的时候会发生什么?

Human: {
  starships(obj, args, context, info) {
    return obj.starshipIDs.map(
      id => context.db.loadStarshipByID(id).then(
        shipData => new Starship(shipData)
      )
    )
  }
}

解析器在这个字段中不仅仅是返回了一个Promise对象,它返回一个 Promises 列表。 Human对象具有他们正在驾驶的Starshipsids列表, 但是我们需要通过这些id来获得真正的Starship对象。

GraphQL 将并发执行这些 Promise,当执行结束返回一个对象列表后, 它将继续并发加载列表中每个对象的 name 字段。

产生结果

当每个字段被解析时,结果被放置到键值映射中,字段名称(或别名)作为键值映射的键, 解析器的值作为键值映射的值,这个过程从查询字段的底部叶子节点开始返回, 直到根 Query 类型的起始节点。最后合并成为能够镜像到原始查询结构的结果, 然后可以将其发送(通常为 JSON 格式)到请求的客户端。

让我们最后一眼看看原来的查询,看看这些解析函数如何产生一个结果:

{
  human(id: 1002) {
    name
    appearsIn
    starships { name }
  }
}

返回:

{
  "data": {
    "human": {
      "name": "Han Solo",
      "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"],
      "starships": [
        { "name": "Millenium Falcon" },
        { "name": "Imperial shuttle" }
      ]
    }
  }
}


返回:






返回:







返回:






返回:






返回:






返回:






返回:






返回:






返回:






返回:







返回:






返回:






返回:






返回:






返回:






返回:






返回:






返回: