为GraphQL设计一个基于URL的查询语法

512 阅读9分钟

目前,如果我们想在GraphQL中使用HTTP缓存,我们必须使用一个支持持久化查询的GraphQL服务器。这是因为持久化查询已经将GraphQL查询存储在服务器中;因此,我们不需要在请求中提供这些信息。

为了让GraphQL服务器也支持通过单一端点进行HTTP缓存,GraphQL查询必须作为URL参数提供。GraphQL over HTTP规范将有望实现这一目标,为所有GraphQL客户端、服务器和库提供一种标准化的语言,以便相互之间进行交互。

但我怀疑,所有试图通过URL参数传递GraphQL查询的尝试都远非理想。这是因为URL参数必须作为一个单行值提供,所以查询将需要被编码或重新格式化,使其难以理解(对于我们人类,而不是机器)。

例如,这是一个GraphQL查询在用空格替换所有的换行符以使其适合单行时的样子。

{ posts(limit:5) { id title @titleCase excerpt @default(value:"No title", condition:IS_EMPTY) author { name } tags { id name } comments(limit:3, order:"date|DESC") { id date(format:"d/m/Y") author { name } content } } }

你能理解它吗?我也不知道。

而这是GraphiQL客户端如何将简单的查询{ posts { id title } } 编码为一个URL参数。

%7B%0A%20%20posts%20%7B%0A%20%20%20%20id%0A%20%20%20%20title%0A%20%20%7D%0A%7D

再一次,我们不知道这里发生了什么。

这两个例子都说明了一个问题:从技术角度来看,单行GraphQL查询可以工作,将信息传输到服务器,但人们阅读和编写这些查询并不容易。

能够用单行查询来操作会有很多好处。例如,我们可以直接在浏览器的地址栏中编写查询,而不需要一些GraphQL客户端。

这并不是说我不喜欢GraphQL客户端--事实上,我喜欢GraphiQL。但我确实不喜欢我依赖它们的想法。

换句话说,我们可以从一个允许人们的查询语法中受益。

  • 直接用单行写一个查询
  • 一目了然地了解单行查询的内容

这是一个艰巨的挑战。但它并不是不可克服的。

在这篇文章中,我将介绍一种替代的语法,它支持被我们人类 "在单行中容易阅读和书写"。

我并不是真的提议将这种语法引入GraphQL--我知道这永远不会发生。但是,这种语法的设计过程还是可以体现出我们在设计GraphQL over HTTP规范时必须注意的问题。

为什么GraphQL的语法在一行中如此难以理解?

让我们先来探讨一下GraphQL语法的问题所在,然后再将其推广到其他语法中。

识别问题

在我看来,困难来自于GraphQL查询中被嵌套的字段,其中的嵌套可以在整个查询中进退自如。正是这种来来去去的行为,使得它在写成一行时难以把握。

如果查询中的嵌套只前进,那么就不难理解了。以这个查询为例。

{
  posts {
    id
    title
    excerpt
    comments {
      id
      date
      content
      author {
        id
        name
        url
        posts {
          id
          title
        }
      }
    }
  }
}

这里,嵌套只往前走。

image.png

GraphQL查询,只向前推进。

当查看这个总是向前推进的查询,并从左到右扫描它时,我们仍然可以理解每个字段属于什么实体。

{ posts { id title excerpt comments { id date content author { id name url posts { id title } } } } }

现在,考虑同样的GraphQL查询,但重新安排字段,使叶子出现在连接之后。

{
  posts {
    id
    comments {
      id
      date
      author {
        posts {
          id
          title
        }
        id
        name
        url
      }
      content
    }
    title
    excerpt
  }
}

在这种情况下,我们可以说字段是前进的,也是后退的。

image.png

GraphQL查询,前进和后退。

这个查询可以用一行来写,像这样。

{ posts { id comments { id date author { posts { id title } id name url } content } title excerpt } }

现在,理解这个查询就不那么容易了。在退级之后(即紧接着连接),我们可能不记得之前是哪个实体,所以我们不会掌握这个字段属于哪里。

image.png

这些字段属于哪个实体?

我猜这与人脑的短期记忆有限有关,每次能容纳的项目不超过几个)。

而当有很多层次的前进和后退时,那么就很难完全掌握了。这个疑问是可以理解的。

{
  posts {
    id
    comments {
      id
      date
      children {
        id
        author {
          name
          url
        }
        content
      }
      author {
        posts {
          id
          title
          tags {
            name
          }
        }
        id
        name
        friends {
          id
          name
        }
        url
      }
      content
    }
    title
    excerpt
  }
  author {
    name
  }
}

但我们没有办法让它的单行等价物有意义。

{ posts { id comments { id date children { id author { name url } content } author { posts { id title tags { name } } id name friends { id name } url } content } title excerpt } author { name } }

总之,GraphQL查询不容易用单行表示,因为它的嵌套行为,我们人类可以理解它。

归纳问题

这个问题不是GraphQL特有的。事实上,它将发生在一个语法中--任何语法--其中的元素前进和后退。

以JSON为例。

{
  "name": "leoloso/PoP",
  "description": "PoP monorepo",
  "repositories": [
    {
      "type": "package",
      "package": {
        "name": "leoloso-pop-api-wp/newsletter-subscriptions-rest-endpoints",
        "version": "master",
        "type": "wordpress-plugin",
        "source": {
          "url": "https://gist.github.com/leoloso/6588f6c1bdcce82fc317052616d3dfb4",
          "type": "git",
          "reference": "master"
        }
      }
    },
    {
      "type": "package",
      "package": {
        "name": "leoloso-pop-api-wp/disable-user-edit-profile",
        "version": "0.1.1",
        "type": "wordpress-plugin",
        "source": {
          "url": "https://gist.github.com/leoloso/4e367eb8d8014a7aa7580567608bd5b4",
          "type": "git",
          "reference": "master"
        }
      }
    },
    {
      "type": "vcs",
      "url": "https://github.com/leoloso/wp-muplugin-loader.git"
    }
  ],
  "minimum-stability": "dev",
  "prefer-stable": true,
  "require": {
    "php": "~8.0",
    "getpop/api-rest": "dev-master",
    "getpop/engine-wp-bootloader": "dev-master"
  },
  "extra": {
    "branch-alias": {
      "dev-master": "1.0-dev"
    },
    "installer-types": [
      "graphiql-client",
      "graphql-voyager"
    ],
    "installer-paths": {
      "wordpress/wp-content/mu-plugins/{$name}/": [
        "type:wordpress-muplugin"
      ],
      "wordpress/wp-content/plugins/{$name}/": [
        "type:wordpress-plugin",
        "getpop/engine-wp-bootloader"
      ]
    }
  },
  "config": {
    "sort-packages": true
  }
}

将其转换为单行,使得它真的很难理解。

{ "name": "leoloso/PoP", "description": "PoP monorepo", "repositories": [ { "type": "package", "package": { "name": "leoloso-pop-api-wp/newsletter-subscriptions-rest-endpoints", "version": "master", "type": "wordpress-plugin", "source": { "url": "https://gist.github.com/leoloso/6588f6c1bdcce82fc317052616d3dfb4", "type": "git", "reference": "master" } } }, { "type": "package", "package": { "name": "leoloso-pop-api-wp/disable-user-edit-profile", "version": "0.1.1", "type": "wordpress-plugin", "source": { "url": "https://gist.github.com/leoloso/4e367eb8d8014a7aa7580567608bd5b4", "type": "git", "reference": "master" } } }, { "type": "vcs", "url": "https://github.com/leoloso/wp-muplugin-loader.git" } ], "minimum-stability": "dev", "prefer-stable": true, "require": { "php": "~8.0", "getpop/api-rest": "dev-master", "getpop/engine-wp-bootloader": "dev-master" }, "extra": { "branch-alias": { "dev-master": "1.0-dev" }, "installer-types": [ "graphiql-client", "graphql-voyager" ], "installer-paths": { "wordpress/wp-content/mu-plugins/{$name}/": [ "type:wordpress-muplugin" ], "wordpress/wp-content/plugins/{$name}/": [ "type:wordpress-plugin", "getpop/engine-wp-bootloader" ] } }, "config": { "sort-packages": true } } 

更重要的是,当语法使用间距来嵌套其元素时,甚至不可能用单行来写。

例如,YAML就是这种情况。

services:
  _defaults:
    public: true
    autowire: true
    autoconfigure: true

  PoP\API\PersistedQueries\PersistedQueryManagerInterface:
    class: \PoP\API\PersistedQueries\PersistedQueryManager

  # Override the service
  PoP\ComponentModel\Schema\FieldQueryInterpreterInterface:
    class: \PoP\API\Schema\FieldQueryInterpreter

  PoP\API\Hooks\:
    resource: '../src/Hooks/*' 

设计一种不同的查询语法

我将描述GraphQL语法的另一种设计:PQL语法,由PoP(我编写的PHP中的GraphQL服务器)的GraphQL使用,接受通过GET

由于GraphQL语法的问题来自于退缩的嵌套字段,解决方案似乎很明显:查询的流程必须是向前的。

PQL是如何实现这一点的呢?为了证明这一点,我们来探讨一下PQL的语法。

字段语法

在GraphQL中,一个字段是这样写的。

{
  alias:fieldName(fieldArgs)@fieldDirective(directiveArgs)
}

在PQL中,一个字段是这样写的。

fieldName(fieldArgs)[@alias]<fieldDirective(directiveArgs)>

所以这很相似,但也有一些区别。

  1. 别名不是放在字段之前,而是放在字段之后。
  2. 别名不是用: ,而是用@ (也可以选择用[...] (用于 "书签",后面会解释))。
  3. 指令不是用@ ,而是用 (并且可以选择用 (用于 "书签",稍后解释))来标识。<...>

这些差异与查询所需的始终向前的流程直接相关。

根据我自己的经验,在浏览器地址栏中直接写查询时,我总是在写完字段名之后,而不是之前,才想到需要别名。因此,使用GraphQL中的顺序,我不得不回溯到那个位置(按左方向键),添加别名,然后回到最终位置(按右方向键)。

这是很麻烦的。把别名放在字段名之后会更有意义,使之成为一个自然的流程。

当在字段名之后定义别名时,使用:.就没有意义了。GraphQL使用这个符号是为了让JSON响应尊重查询的形状。一旦字段和别名之间的顺序被颠倒,使用@ 似乎就很自然了。

这又意味着我们不能再使用@ 来识别指令。相反,我选择了一个周围的语法<...> (例如,<directiveName> ),这样指令也可以被嵌套(例如,<directive1<directive2>> ),使GraphQL by PoP支持可组合的指令功能成为可能。

领域

在GraphQL中,我们可以通过在它们之间添加空格或换行来添加两个或更多的字段。

{
  foo
  bar
}

在PQL中,我们使用字符| 来分隔字段。

foo|bar

我们已经可以直观地看到查询是如何组成一个单行的。

  • 没有{} 字符
  • 没有空格或换行符

我们还可以体会到,查询可以直接在浏览器中组成,通过URL参数query

例如,执行查询的URLid|__typename 是:${endpoint}?query=id|__typename

使用DevTools,我们可以看到GraphQL单个端点是如何支持HTTP缓存的。

image.png

GraphQL单一端点的HTTP缓存。

对于下面演示的所有查询,会有一个链接_在浏览器中执行查询_。点击它们,可以看到PQL在生产中的实际网站上是如何工作的。

使查询具有视觉上的吸引力

与GraphQL类似,换行符(还有空格)没有增加语义。因此,我们可以方便地添加换行符来帮助可视化查询。

foo|
bar

在使用火狐浏览器时,可以将此查询复制(从文本编辑器、网页等)并粘贴到浏览器的地址栏中,所有的换行符都会被自动删除,从而形成等效的单行查询。

在Firefox中复制/粘贴查询。

连接

GraphQL使用字符{} 来定义连接的数据。

{
  posts {
    author {
      id
    }
  }
}

在PQL中,查询只能前进,不能后退。因此,有一个等同于{ ,也就是. ,但没有等同于} ,因为不需要它。

posts.
  author.
    id

在浏览器中执行查询.

我们可以把|. 结合起来,为任何实体获取多个字段。考虑一下这个GraphQL查询。

{
  posts {
    id
    title
    author {
      id
      name
      url
    }
  }
}

它在PQL中的等价物将是。

posts.
  id|
  title|
  author.
    id|
    name|
    url

在浏览器中执行查询.

在这一点上,我们可以面对挑战:PQL如何只接受前进的字段?

仅限前进的流程的语法

上面看到的查询都是前进的。现在让我们来处理那些也需要撤退的查询,比如这个GraphQL查询。

{
  posts {
    id
    author {
      id
      name
      url
    }
    comments {
      id
      content
    }
  }
}

PQL利用字符, 来连接元素。它类似于| ,用于连接字段,但有一个根本区别:, 右边的字段从根部开始再次遍历图。

那么,上面的查询在PQL中就有这样的等价物。

posts.
  id|
  author.
    id|
    name|
    url,
posts.
  comments.
    id|
    content

在浏览器中执行查询.

请注意,为了在视觉上吸引人,name|url 的左边有相同的填充,因为| 保持着相同的路径posts.author. 。但是在, 之后就没有左边的填充了,因为查询又从根开始了。

我们可以认为这个查询也是撤退的。

image.png

PQL中的前进和后退的查询。

在GraphQL中,我们可以回到查询中的前一个位置--即图中的父节点,其次数与我们所穿越的级别相同。但在PQL中,我们不能这样做:我们总是一路返回到图的根部。

再次从根部开始,我们必须再次指定到节点的完整路径,以继续添加字段。这使得查询更加冗长。例如,上面的查询中的posts 路径在GraphQL中出现一次,但在PQL中出现两次。

这种冗余迫使我们人类在读写图中每一层的查询时都要重新创建路径。这样做可以使我们在单行表达时理解查询。

posts.id|author.id|name|url,posts.comments.id|content

因为我们在脑海中重新创建路径,所以我们不会出现短期记忆的问题,这种问题导致我们在查看GraphQL查询时迷失方向。

用书签来消除冗长的文字

必须重新创建通往节点的整个路径可能会成为一种困扰。

考虑一下这个GraphQL查询。

{
  users {
    posts {
      author {
        id
        name
      }
      comments {
        id
        content
      }
    }
  }
}

以及它在PQL中的等价物。

users.
  posts.
    author.
      id|
      name,
users.
  posts.
    comments.
      id|
      content

在浏览器中执行查询.

为了检索comments 字段,我们需要再次添加users.posts. 。图的层次越深,复制的路径就越长。

为了解决这个问题,PQL引入了一个新的概念:"书签",它提供了一条通往已经走过的路径的捷径,这样我们就可以方便地从这一点上继续加载数据。

当我们在某个路径上迭代时,我们用[...] ,来定义一个书签,然后在引用其书签时,同样用[...] ,从查询的根部自动检索该路径。

在上面的查询中,我们可以把users.posts 作为书签[userposts]

users.
  posts[userposts].
    author.
      id|
      name,
[userposts].
  comments.
    id|
    content

在浏览器中执行查询.

为了更容易理解,我们还可以在所应用的书签左边添加相应的填充,与它的路径的填充相匹配(这样,comments 出现在posts 下面)。

users.
  posts[userposts].
    author.
      id|
      name,
  [userposts].
    comments.
      id|
      content

有了书签,我们仍然可以在单行表达时理解查询。

users.posts[userposts].author.id|name,[userposts].comments.id|content

如果我们需要同时定义一个书签和一个别名,我们可以让@ 符号嵌入到[...]

users.
  posts[@userposts].
    author.
      id|
      name,
  [userposts].
    comments.id|
    content

在浏览器中执行查询.

简化字段参数

在GraphQL中,String 字段参数中的值必须用引号括起来"..."

{
  posts {
    id
    title
    date(format: "d/m/Y")
  }
}

事实证明,在浏览器中组成查询时,必须输入这些引号是非常烦人的;我经常会忘记它们,然后不得不用方向键左右导航来添加它们。

因此,在PQL中,字符串引号可以被省略。

posts.
  id|
  title|
  date(format:d/m/Y)

在浏览器中执行查询.

字符串引号是必要的,否则查询就会被打乱。

posts.
  id|
  title|
  date(format:"d M, Y")

在浏览器中执行查询.

此外,字段参数有时可以是隐含的;例如,当字段只有一个字段参数时。在这种情况下,PQL允许省略它。

posts.
  id|
  title|
  date(d/m/Y)

在浏览器中执行查询.

变量

在GraphQL中,变量被定义在请求的主体中,作为一个编码的JSON。

{
  "query":"query ($format: String) {
    posts {
      id
      title
      date(format: $format)
    }
  }",
  "variables":"{
    \"format\":\"d/m/Y\"
  }"
}

相反,PQL使用HTTP标准输入,通过$_GET$_POST 传递变量。

?query=
  posts.
    id|
    title|
    date($format)
&format=d/m/Y

在浏览器中执行查询.

我们也可以在输入下传递变量variables

?query=
  posts.
    id|
    title|
    date($format)
&variables[format]=d/m/Y

在浏览器中执行查询.

片段

GraphQL采用片段来重复使用查询部分。

{
  users {
    ...userData
    posts {
      comments {
        author {
          ...userData
        }
      }
    }
  }
}

fragment userData on User {
  id
  name
  url
}

在PQL中,片段的定义方法与变量相同:作为$_GET$_POST 中的输入。它们用-- 来引用。

?query=
  users.
    --userData|
    posts.
      comments.
        author.
          --userData
&userData=
  id|
  name|
  url

在浏览器中执行查询.

片段也可以在输入下定义fragments

?query=
  users.
    --userData|
    posts.
      comments.
        author.
          --userData
&fragments[userData]=
  id|
  name|
  url

在浏览器中执行查询.

在GraphQL和PQL语法之间转换查询

PQL是GraphQL查询语法的一个超集。因此,任何使用标准 GraphQL 语法编写的查询也可以用 PQL 编写。

相反,并不是每个用PQL编写的查询都可以用GraphQL语法编写,因为PQL支持GraphQL不支持的功能,如可组合字段可组合指令

PQL包括大部分相同的元素。

  • 字段和字段参数
  • 指令和指令参数
  • 别名
  • 片段
  • 变量

它不支持的元素有

  • 操作
  • 操作名称,变量定义,和默认变量
  • on 元素,以表明片段必须应用于什么类型/接口。

即使不支持这些元素,它们的基本功能也通过不同的方法得到支持。

操作缺失是因为不再需要它了:我们现在可以选择使用GET (用于查询)或POST (用于突变)来请求查询。

在GraphQL中,只有当文档包含许多操作,而我们需要指定执行哪一个操作时,才需要操作名称,或者可能一起执行几个操作,通过@export

在前一种情况下,对于PQL来说就不需要了--我们只传递必须执行的查询,而不是所有的查询。

在后一种情况下,多个操作可以在一个请求中一起执行,同时保证它们的执行顺序,通过用;,像这样连接它们

posts.
  author.
    id|
    name|
    url;

posts.
  comments.
    id|
    content

在浏览器中执行查询.

在GraphQL中,变量定义被用来定义变量的类型,使GraphiQL等客户端能够在类型不同时显示错误。这是一个很好的功能,但对于执行查询本身来说,并不是真的需要。

默认变量值可以像任何变量一样被定义:通过URL参数。

on 元素是不需要的,因为我们可以使用指令@include ,通过一个可组合的字段isType 作为参数,来找出对象的类型,并根据这个值,应用预定的片段或不应用。

例如,以这个GraphQL查询为例。

{
  customPosts {
    __typename
    ... on Post {
      title
    }
  }
}

这相当于PQL。

customPosts.
  __typename|
  title<include(if:isType(Post))>

在浏览器中执行查询.

转换自省查询

让我们把GraphiQL(和其他客户端)用来获取模式元数据的自省查询从GraphQL语法转换为PQL。

这个自省查询就是这个

query IntrospectionQuery {
  __schema {
    queryType {
      name
    }
    mutationType {
      name
    }
    subscriptionType {
      name
    }
    types {
      ...FullType
    }
    directives {
      name
      description
      locations
      args {
        ...InputValue
      }
    }
  }
}

fragment FullType on __Type {
  kind
  name
  description
  fields(includeDeprecated: true) {
    name
    description
    args {
      ...InputValue
    }
    type {
      ...TypeRef
    }
    isDeprecated
    deprecationReason
  }
  inputFields {
    ...InputValue
  }
  interfaces {
    ...TypeRef
  }
  enumValues(includeDeprecated: true) {
    name
    description
    isDeprecated
    deprecationReason
  }
  possibleTypes {
    ...TypeRef
  }
}

fragment InputValue on __InputValue {
  name
  description
  type {
    ...TypeRef
  }
  defaultValue
}

fragment TypeRef on __Type {
  kind
  name
  ofType {
    kind
    name
    ofType {
      kind
      name
      ofType {
        kind
        name
        ofType {
          kind
          name
          ofType {
            kind
            name
            ofType {
              kind
              name
              ofType {
                kind
                name
              }
            }
          }
        }
      }
    }
  }
}

其对应的PQL查询是这样的。

?query=
    __schema[schema].
        queryType.
            name,
    [schema].
        mutationType.
            name,
    [schema].
        subscriptionType.
            name,
    [schema].
        types.
            --FullType,
    [schema].
        directives.
            name|
            description|
            locations|
            args.
                --InputValue
&fragments[FullType]=
    kind|
    name|
    description|
    fields(includeDeprecated: true)[@fields].
        name|
        description|
        args.
            --InputValue,
    [fields].
        type.
            --TypeRef,
    [fields].
        isDeprecated|
        deprecationReason,
    [fields].
        inputFields.
            --InputValue,
    [fields].
        interfaces.
            --TypeRef,
    [fields].
        enumValues(includeDeprecated: true)@enumValues.
            name|
            description|
            isDeprecated|
            deprecationReason,
    [fields].
        possibleTypes.
            --TypeRef
&fragments[InputValue]=
    name|
    description|
    defaultValue|
    type.
        --TypeRef
&fragments[TypeRef]=
    kind|
    name|
    ofType.
        kind|
        name|
        ofType.
            kind|
            name|
            ofType.
                kind|
                name|
                ofType.
                    kind|
                    name|
                    ofType.
                        kind|
                        name|
                        ofType.
                            kind|
                            name|
                            ofType.
                                kind|
                                name

在浏览器中执行查询。(注意,这个链接中的查询与上面的查询略有不同,因为我仍然需要在片段中添加对, token的支持。)

而这是用单行书写的查询。

?query=__schema[schema].queryType.name,[schema].mutationType.name,[schema].subscriptionType.name,[schema].types.--FullType,[schema].directives.name|description|locations|args.--InputValue&fragments[FullType]=kind|name|description|fields(includeDeprecated: true)[@fields].name|description|args.--InputValue,[fields].type.--TypeRef,[fields].isDeprecated|deprecationReason,[fields].inputFields.--InputValue,[fields].interfaces.--TypeRef,[fields].enumValues(includeDeprecated: true)@enumValues.name|description|isDeprecated|deprecationReason,[fields].possibleTypes.--TypeRef&fragments[InputValue]=name|description|defaultValue|type.--TypeRef&fragments[TypeRef]=kind|name|ofType.kind|name|ofType.kind|name|ofType.kind|name|ofType.kind|name|ofType.kind|name|ofType.kind|name|ofType.kind|name

一些更多的例子

这个查询有一个包含嵌套路径、变量、指令和其他片段的片段。

?query=
  posts(limit:$limit, order:$order).
    --postData|
    author.
      posts(limit:$limit).
        --postData
&postData=
  id|
  title|
  --nestedPostData|
  date(format:$format)
&nestedPostData=
  comments<include(if:$include)>.
    id|
    content
&format=d/m/Y
&include=true
&limit=3
&order=title

在浏览器中执行查询.

这个查询将一个指令应用于一个片段,然后将其应用于该片段中的所有字段。

?query=
  posts.
    id|
    --props<include(if:hasComments())>
&fragments[props]=
  title|
  date

在浏览器中执行查询.

最后,在这篇博文中,有许多直接作为URL参数嵌入的单行查询的例子,并包含了PQL语法的额外属性(本文没有描述)。

结论

为了支持HTTP缓存,我们目前必须使用支持持久化查询的GraphQL服务器。

但是,GraphQL单个端点呢?它能不能也支持HTTP缓存?如果是这样的话,是否可以做到让人们编写查询,而不必依赖客户端或库?

对这些问题的回答是:是的,它可以做到。然而,由于GraphQL语法的嵌套行为,它目前阻碍了这种方式。

在这篇文章中,我展示了一种替代语法,称为PQL,它可以使GraphQL服务器通过URL参数接受查询,同时使人们能够在一行中读写查询,甚至直接在浏览器的地址栏中读写。

为GraphQL设计一个基于URL的查询语法》一文首次出现在LogRocket博客上。