🤔 新的 Gato GraphQL 为何花了 1.5 年才发布?
Gato GraphQL 的 0.9 版本已经正式发布。此次发布历经近 1.5 年的开发时间,以及超过 16000 次提交。这确实是一段漫长的时间!
在 Hacker News 上分享这则公告时,我收到了如下问题:
[...] 我很好奇 16k 次提交究竟包含了什么。我参与过的提交数超过一万的项目,都有几十甚至几百名人员全职工作。[...] 是否存在文章中没有提及的、需要克服的复杂性?
提交数并不是一个非常可靠的指标,因为我可能只做了一个非常简单的改动就单独推送了一次提交。16k 次提交中,有很多都是 "typo" 修正,或者只是改进了某个 README 中的描述。
尽管如此,提交数确实能够反映实际的工作量。其中也有大量包含密集修改的提交,有时一次提交就包含了几十甚至数百处变更。版本 0.8 与 0.9 之间的变化确实巨大,完成这些工作需要付出大量努力和时间。
在这篇博客文章中,我将描述这些变化的内容,以解释为何花费了这么长时间。同时,我也会预览一些已添加到代码库中的高级功能——它们将在即将发布的 1.0 版本中正式亮相。
GraphQL 服务器的背景
首先,我将简要介绍这个引擎的历史,以及它的工作原理相关的技术细节。
(这部分内容主要面向开发者;如果您对技术细节不感兴趣,欢迎直接跳到下一节。)
Gato GraphQL 基于 PoP 构建,PoP 是一个用 PHP 渲染组件的引擎(类似于 JavaScript 中的 React 或 Vue)。对这个引擎的依赖是绝对的,因此该插件托管在 GitHub 上的 GatoGraphQL/GatoGraphQL monorepo 中。
在底层,这种依赖关系如下所示:
Gato GraphQL 解析 GraphQL query 的方式是:先将其转换为等效的组件模型,然后由 PoP 解析该模型以获取所有所需数据,最后将数据整形为 GraphQL query 的结构。
大约在 2013/2014 年开始开发 PoP 时,GraphQL 尚不存在,将组件模型解析为数据的方法论是从零开始设计和实现的。没有参照模型(如概念层面的 GraphQL,以及实现层面的 graphql-js 参考项目)这一点,正如我稍后将解释的,既是阻碍,也是馈赠。
PoP 最初被设计为在服务端将整个网站渲染为 HTML,同时在页面 URL 后附加 ?output=json 时以 JSON 格式暴露原始数据,并通过额外的 URL 参数进一步筛选要获取的数据(设置、DB 对象数据)。
请点击以下链接(所有链接都指向同一个网页,只是 URL 参数不同),看看它们有何区别:
- HTML 内容:mesym.com/en/posts/
- 原始 JSON 数据(设置 + DB):mesym.com/en/posts/?output=json
- 原始 JSON 数据(DB):mesym.com/en/posts/?output=json&module=data
点击最后一个链接时,一个认识油然而生:这几乎就是 GraphQL!唯一的重大区别在于响应中的数据是隐式的,因为它已经由页面中包含的组件(PHP)预先定义了。而 GraphQL 则允许我们通过 query 来决定要获取哪些数据。
所以当我在 2019 年前后了解到 GraphQL 时,让 PoP 也满足 GraphQL 服务器的需求对我来说是顺理成章的事。需要做的只是接受 GraphQL query 作为输入,并根据 query 动态创建组件模型。
我就这样做了,而且效果不错。但速度很慢,因为 PoP 理解的是自己的输入格式,所以 GraphQL query 必须被适配为 PoP 格式:
- 解析 GraphQL query;然后
- 将 query 转换为 PoP 格式;然后
- 解析 PoP 格式
GraphQL query 因此被解析了两次(一次为 GraphQL,一次为 PoP),而 PoP 格式并不是通过 AST 来解析的,而是一次又一次地解析 query 字符串。(不使用 AST 是糟糕的编码方式,但我没有可参考的规范,开发是有机演进的,简单的 substr(...) 每天都能救场。)
这就是为什么我说没有 GraphQL 规范是一种阻碍——我的方案速度很慢(这是版本 0.8 时的状况)。所以我决定修复它。
将引擎转变为 GraphQL-first
我决定的解决方案是让 PoP 原生支持 GraphQL 语言。这样,将 GraphQL query 作为输入传递给 PoP 就能直接转换为组件模型,无需任何额外的适配器,也无需重复处理。
这意味着 PoP 项目需要重新定位:从一个为服务端渲染网站组件、经改造以解析 GraphQL query 的 PHP 库,转变为一个真正的 GraphQL 服务器。
代码库随后经历了大规模的变革,引入了 GraphQL AST 作为引擎内所有 PHP 服务之间传递状态的基础。GraphQL AST 对象现在成为 PoP 的输入(而非 query 字符串)。
PHP 中的其他 GraphQL 服务器依赖于 graphql-php,但 Gato GraphQL 插件并不依赖它。这在维护方面是个坏消息(因为我无法复用他人的代码),但在独立性方面是好消息:我可以按照自己的节奏和标准为插件添加自定义功能(这也是为什么该插件已经提供了「oneof」输入对象)。
正如下面的章节所示,这是一个巨大的优势。
将原创功能融入 GraphQL
GraphQL 通常与数据获取相关联。当然,您可以从 Gato GraphQL 获取任何数据(文章、用户、评论等):
query {
posts(
pagination: { limit: 5, offset: 20 }
sort: { by: DATE, order: ASC }
) {
id
title
content
url
author {
id
name
url
}
comments {
id
date
content
}
}
}但这只是唾手可得的成果。GraphQL 还可以用于许多其他用途,包括数据操作和转换,甚至将 GraphQL 置于管道中以在服务之间进行中介。
以下是 GraphQL 大有用武之地的一些示例:
- 从一个或多个来源提取信息(例如 WordPress 站点的用户和 Mailchimp 的通讯订阅者数据),合并数据,并将其作为单一数据集进行整体分析
- 执行操作以适应站点上的内容:
- 一次性操作,例如将站点迁移到另一个域时,将内容和元数据中所有的
"www.myoldsite.com"替换为"mynewsite.com" - 持续性操作,例如每当作者发布新博客文章时,将所有
"http://"替换为"https://"
- 一次性操作,例如将站点迁移到另一个域时,将内容和元数据中所有的
- 连接 Google Translate API 将所有博客文章翻译为另一种语言
- 博客文章发布后自动发送推文
PoP 被设计为支持这些其他用例,通过 GraphQL(本身)不支持的功能来实现:
- 支持「功能」字段(除「数据」字段之外),这些字段被添加到 schema 中的所有类型
- 在同一 query 中,将一个字段的结果作为输入传递给另一个字段
- 组合指令,使一个指令能够修改另一个指令的行为
- 根据字段的值动态决定是否应用某个指令
而我确实不想从 GraphQL 服务器中移除这些功能:我已经编写了它们,而且它们确实很有价值。
v0.9 花费如此之长时间的第二个原因是,我还必须找到一种方法,以不破坏 GraphQL 规范的方式将这些新颖功能融入 GraphQL(例如,向 GraphQL 语法引入新元素是不可接受的)。
GraphQL 中数据操作的示例
插件中引入到 GraphQL 的新功能将在不久的将来随版本 1.0 的发布而更加清晰可见。但您现在已经可以体验其中的一部分。
以下 GraphQL query 从外部 REST API 获取用户条目列表(可以通过 @remove 从响应中移除);将此数据输入同一 query 内的另一个字段;从每个条目中提取 email 属性;最后,仅当同一条目中的语言为英语或德语时,将 email 转换为大写:
###################################################################
# Fetch data from a REST endpoint, extract the emails, and make
# uppercase those ones from users with a special language.
###################################################################
query ExtractEmailsFromAPIAndUpperCaseSpecialOnes
{
# Retrieve data from a REST API endpoint
userEntries: _sendJSONObjectCollectionHTTPRequest(
input: {
url: "https://newapi.getpop.org/wp-json/newsletter/v1/subscriptions"
}
) # @remove # <= Uncomment this directive to not print the API data
emails: _echo(value: $__userEntries)
# Iterate all the entries, passing every entry
# (under the dynamic variable $userEntry)
# to each of the next 4 directives
@underEachArrayItem(
passValueOnwardsAs: "userEntry"
affectDirectivesUnderPos: [1, 2, 3, 4]
)
# Extract property "lang" from the entry
# via the functionality field `_objectProperty`,
# and pass it onwards as dynamic variable $userLang
@applyField(
name: "_objectProperty"
arguments: {
object: $userEntry,
by: {
key: "lang"
}
}
passOnwardsAs: "userLang"
)
# Execute functionality field `_inArray` to find out
# if $userLang is either "en" or "de", and place the
# result under dynamic variable $isSpecialLang
@applyField(
name: "_inArray"
arguments: {
value: $userLang,
array: ["en", "de"]
}
passOnwardsAs: "isSpecialLang"
)
# Extract property "email" from the entry
# and set it back as the value for that entry
@applyField(
name: "_objectProperty"
arguments: {
object: $userEntry,
by: {
key: "email"
}
}
setResultInResponse: true
)
# If $isSpecialLang is `true` then execute
# directive `@strUpperCase`
@if(condition: $isSpecialLang)
@strUpperCase
}这是响应结果(请注意只有某些 email 被转换为了大写):
{
"data": {
"userEntries": [
{
"email": "abracadabra@ganga.com",
"lang": "de"
},
{
"email": "longon@caramanon.com",
"lang": "es"
},
{
"email": "rancotanto@parabara.com",
"lang": "en"
},
{
"email": "quezarapadon@quebrulacha.net",
"lang": "fr"
},
{
"email": "test@test.com",
"lang": "de"
},
{
"email": "emilanga@pedrola.com",
"lang": "fr"
}
],
"emails": [
"ABRACADABRA@GANGA.COM",
"longon@caramanon.com",
"RANCOTANTO@PARABARA.COM",
"quezarapadon@quebrulacha.net",
"TEST@TEST.COM",
"emilanga@pedrola.com"
]
}
}亲自试试吧!按下"Run"按钮来执行这个 query:
###################################################################
# Fetch data from a REST endpoint, extract the emails, and make
# uppercase those ones from users with a special language.
###################################################################
query ExtractEmailsFromAPIAndUpperCaseSpecialOnes {
# Retrieve data from a REST API endpoint
userEntries: _sendJSONObjectCollectionHTTPRequest(
input: {
url: "https://newapi.getpop.org/wp-json/newsletter/v1/subscriptions"
}
)
# @remove # <= Uncomment this directive to not print the API data
emails: _echo(value: $__userEntries)
# Iterate all the entries, passing every entry
# (under the dynamic variable $userEntry)
# to each of the next 4 directives
@underEachArrayItem(
passValueOnwardsAs: "userEntry"
affectDirectivesUnderPos: [1, 2, 3, 4]
)
# Extract property "lang" from the entry
# via the functionality field `_objectProperty`,
# and pass it onwards as dynamic variable $userLang
@applyField(
name: "_objectProperty"
arguments: { object: $userEntry, by: { key: "lang" } }
passOnwardsAs: "userLang"
)
# Execute functionality field `_inArray` to find out
# if $userLang is either "en" or "de", and place the
# result under dynamic variable $isSpecialLang
@applyField(
name: "_inArray"
arguments: { value: $userLang, array: ["en", "de"] }
passOnwardsAs: "isSpecialLang"
)
# Extract property "email" from the entry
# and set it back as the value for that entry
@applyField(
name: "_objectProperty"
arguments: { object: $userEntry, by: { key: "email" } }
setResultInResponse: true
)
# If $isSpecialLang is `true` then execute
# directive `@strUpperCase`
@if(condition: $isSpecialLang)
@strUpperCase
}我曾提到不受 GraphQL 规范约束是一种阻碍,但(回过头来看)也是一种馈赠。正是因为没有 GraphQL 规范的束缚,我才能自由地构想这些新颖的功能。
现在,这些功能已经迁移到 Gato GraphQL 中,它可以成为 WordPress 站点内容获取、操作和转换的强大助手。(尽管这些功能只有在即将发布的 v1.0 中才能访问。)
花费了一些时间,但这些努力绝对是值得的。
快来试试吧!
您是否相信这漫长的等待是值得的?希望如此!
请下载插件并亲自体验:
想要获取有关开发进展、新文档及即将发布版本(包括 v1.0)的最新资讯?欢迎订阅我们的通讯。
想要在 GitHub 上探索开源代码?查看 GatoGraphQL/GatoGraphQL(欢迎给我们点个 star……我们热爱 star!⭐️⭐️⭐️)
顺便问一下,您在 WordPress 上需要进行哪些内容转换(您可能已经在使用某些专用商业插件来完成的)?请告诉我您的用例。
如果您喜欢这些内容,请与您的朋友和同事分享,传播这份热爱 ❤️。