👨🏻💻 GraphQL 作为(某种意义上的)编程语言
GraphQL 虽然拥有 GraphQL 语言,但通常不会被称为编程语言,因为编程语言能做的很多事情是 GraphQL 做不到的。
GraphQL 通常用于获取数据(例如在客户端渲染网站)和修改数据(例如创建一篇文章)。这基本上就是它的全部用途。
(其他用途只是这两种情况的组合。例如,API 网关可以从不对外暴露的内部服务器获取或修改数据。)
GraphQL 中的数据访问:
query PrintPostTitle($postID: ID!)
{
post(by: { id: $postID }) {
title
}
}...在 PHP 中有如下(某种程度上的)等价写法:
function printPostTitle(int $postID)
{
$post = getPost($postID);
echo $post->title;
}(以下所有示例均使用 PHP 作为对比的编程语言。)
GraphQL 中的数据修改:
query UpdatePost($postID: ID!, $title: String!)
{
updatePost(
by: { id: $postID },
input: { title: $title }
) {
title
}
}...在 PHP 中有如下(某种程度上的)等价写法:
function updatePost(int $postID, string $title)
{
$post = getPost($postID);
$post->update(['title' => $title]);
}这已经足够,因为 GraphQL 通常从客户端(用 JavaScript、PHP、Java 等编程语言编写)访问,客户端包含处理数据的逻辑。因此,GraphQL 并非独立使用,而是作为其他系统的辅助工具。
但如果 GraphQL 可以独立使用,那么许多新的使用场景就可以仅凭 GraphQL 解决,从而让 GraphQL 部署到全新的环境,并在应用栈中承担更多职责。
为此,GraphQL 必须支持编程语言的许多特性。
GraphQL 所支持的编程语言特性是有限的。例如,使用指令 @include(或 @skip)并传入一个变量作为输入,可以被视为(某种程度上的)条件逻辑:
query PrintPostProperties($postID: ID!, $addContent: Boolean!)
{
post(by: { id: $postID }) {
title
content @include(if: $addContent)
}
}这个 Query 在 PHP 中的等价写法如下:
function printPostProperties(int $postID, bool $addContent)
{
$post = getPost($postID);
echo $post->title;
if ($addContent) {
echo $post->content;
}
}这基本上就是 GraphQL 的极限了。GraphQL 缺乏递归、动态变量(即值在运行时被计算并赋给变量,而非作为字典中的输入传入)、变量赋值(例如将字段的输出赋给一个变量,然后将其作为参数传入另一个字段)等功能。
请思考一下:仅使用 GraphQL,你将如何解决以下问题?
- 创建一个 Webhook,每当有新用户注册某服务时被该服务调用;用户可能订阅了新闻邮件(通过 Webhook 载荷中的
marketing_optin字段标识);如果订阅了,Webhook 必须将用户的电子邮件地址(Webhook 载荷中的email字段)注册到 Mailchimp 列表中。
你觉得这可行吗?容易吗?困难吗?还是不可能?
在 Gato GraphQL 中,我们希望仅使用 GraphQL 解决这个问题——以及更多问题。这就是为什么我们深入思考如何支持编程语言的特性。
让我们来探索我们在 GraphQL 服务器上支持了哪些编程特性。在本文末尾,我们将看到如何解决上述问题。
功能性(Functionality)
GraphQL 中的字段通常用于带回数据,比如文章的标题、内容或数据。但我们也可以将字段实现为"功能性"。
例如,在 PHP 中打印时间:
function printTime()
{
echo time();
}...可以用 GraphQL 中的 _time 字段实现:
{
_time
}注意,函数 time 不属于任何类型,因此字段 _time 也不属于。因此,它是一个全局字段,可以从 GraphQL schema 的每种类型下访问:
{
posts {
_time
}
}其他功能性字段示例:
_arrayItem_arrayJoin_date_equals_inArray_intAdd_isEmpty_isNull_makeTime_objectProperty_sprintf_strContains_strRegexReplace_strSubstr
函数(Functions)
我们可以将逻辑单元拆分成函数,并让一个函数调用另一个函数:
function printPostProperties(int $postID)
{
$post = getPost($postID);
printPostTitle();
printPostContent();
}
function printPostTitle(Post $post)
{
echo $post->title;
}
function printPostContent(Post $post)
{
echo $post->content;
}在 GraphQL 中,我们同样可以将文档中的 query(或 mutation)操作拆分成多个 query 操作,并让某个操作"依赖"其他操作,从而先执行那些操作:
query PrintPostTitle($postID: ID!)
{
postWithTitle: post(by: { id: $postID }) {
title
}
}
query PrintPostContent($postID: ID!)
{
postWithContent: post(by: { id: $postID }) {
content
}
}
query PrintPostProperties
@depends(on: [
"PrintPostTitle",
"PrintPostContent"
])
{
# ...
}在这个 Query 中,向端点传递 ?operationName=PrintPostProperties 来执行 GraphQL Query 时,将首先执行 PrintPostTitle 和 PrintPostContent 这两个 Query,然后才执行 PrintPostProperties。
这通过 Multiple Query Execution 得以实现。
动态变量(Dynamic Variables)
我们可以在运行时计算一个值并将其赋给变量。然后根据该值,有条件地执行某些功能:
function printPostProperties(int $postID)
{
$post = getPost($postID);
echo $post->title;
$addContent = isUserLoggedIn();
if ($addContent) {
echo $post->content;
}
}在 GraphQL 中,我们可以在某个操作中将一个值"导出"到动态变量,然后在另一个操作中读取该值:
query ExportAddContent
{
addContent: isUserLoggedIn
@export(as: "addContent")
}
query PrintPostProperties($postID: ID!)
@depends(on: "ExportAddContent")
{
post(by: { id: $postID }) {
title
content @include(if: $addContent)
}
}注意,变量 $addContent 保存的是运行时计算出的值,它被 PrintPostProperties 操作读取,但并未在该操作中声明,因为它是一个动态变量。
条件执行函数
前一个示例的替代方案是将逻辑分组到函数中,然后根据动态变量的值有条件地决定是否执行某个函数:
function printPostProperties(int $postID)
{
$post = getPost($postID);
printPostTitle();
$addContent = isUserLoggedIn();
if ($addContent) {
printPostContent();
}
}
function printPostTitle(Post $post)
{
echo $post->title;
}
function printPostContent(Post $post)
{
echo $post->content;
}在 GraphQL 中,我们可以在操作上添加 @include 指令:
query ExportAddContent
{
addContent: isUserLoggedIn
@export(as: "addContent")
}
query PrintPostTitle($postID: ID!)
{
postWithTitle: post(by: { id: $postID }) {
title
}
}
query PrintPostContent($postID: ID!)
@depends(on: "ExportAddContent")
@include(if: $addContent)
{
postWithContent: post(by: { id: $postID }) {
content
}
}
query PrintPostProperties
@depends(on: [
"PrintPostTitle",
"PrintPostContent"
])
{
# ...
}现在,只有当 $addContent 为 true 时,PrintPostContent 操作才会被执行。
变量赋值与回传输入
让我们对前一个示例稍作修改——在前一个示例中,条件 "addContent" 与用户是否登录绑定。
在这个新示例中,"addContent" 在今天是周末时为 true,这需要一些计算逻辑:
- 获取今天的日期
- 将其格式化为星期名称,并转为小写
- 检查是否为
"saturday"或"sunday"
PHP 的写法:
function addContent()
{
$today = time();
$dayName = date('l', $today);
$lcDayName = strtolower($dayName);
$isWeekend = in_array(
$lcDayName,
['saturday', 'sunday']
);
return $isWeekend;
}
function printPostProperties(int $postID)
{
$post = getPost($postID);
echo $post->title;
$addContent = addContent();
if ($addContent) {
echo $post->content;
}
}GraphQL 的写法:
query ExportAddContent
{
today: _time
dayName: _date(format: "l", timestamp: $__today)
lcDayName: _strLowerCase(text: $__dayName)
isWeekend: _inArray(
value: $__lcDayName
array: ["saturday", "sunday"],
)
@export(as: "addContent")
}
query PrintPostProperties($postID: ID!)
@depends(on: "ExportAddContent")
{
post(by: { id: $postID }) {
title
content @include(if: $addContent)
}
}在 ExportAddContent 操作中,每个被查询字段的值会立即以动态变量 $__fieldName 的形式提供给下方的字段。这样,一个字段的输出可以在同一操作内立即用作另一个字段的输入。
这通过 Field to Input 得以实现。
动态修改值
在这个 PHP 示例中,当登录用户是管理员时,我们修改变量的值,在这种情况下,文章内容中会添加一个编辑文章的链接:
function isAdminUser()
{
$user = getCurrentUser();
return in_array("administrator", $user->roles);
}
function printPostContent(int $postID)
{
$post = getPost($postID);
$postContent = $post->content;
$isAdminUser = isAdminUser();
if ($isAdminUser) {
$postContent = sprintf(
'%s<p><a href="%s">%s</a></p>',
$postContent,
$post->edit_url,
'(Admin only) Edit post'
)
}
echo $postContent;
}在 GraphQL 中,我们可以有条件地执行某个操作或另一个操作,为某些字段产生不同的值:
query InitializeDynamicVariables
{
isAdminUser: _echo(value: false)
@export(as: "isAdminUser")
}
query ExportConditionalVariables
@depends(on: "InitializeDynamicVariables")
{
me {
roleNames
isAdminUser: _inArray(
value: "administrator",
array: $__roleNames
)
@export(as: "isAdminUser")
}
}
query RetrieveContentForAdminUser($postId: ID!)
@depends(on: "ExportConditionalVariables")
@include(if: $isAdminUser)
{
post(by: { id : $postId }) {
originalContent: content
wpAdminEditURL
content: _sprintf(
string: "%s<p><a href=\"%s\">%s</a></p>",
values: [
$__originalContent,
$__wpAdminEditURL,
"(Admin only) Edit post"
]
)
}
}
query RetrieveContentForNonAdminUser($postId: ID!)
@depends(on: "ExportConditionalVariables")
@skip(if: $isAdminUser)
{
post(by: { id : $postId }) {
content
}
}
query ExecuteAll
@depends(on: [
"RetrieveContentForAdminUser",
"RetrieveContentForNonAdminUser"
])
{
# ...
}通过对相同的动态变量使用 @include 和 @skip 指令,RetrieveContentForAdminUser 和 RetrieveContentForNonAdminUser 操作互相排斥。
迭代数组
假设我们想遍历数组中的元素,并将这些值转换为大写:
function printUserRolesAsUppercase(int $userID)
{
$user = getUser($userID);
foreach ($user->roles as $role) {
echo strtoupper($role);
}
}在 GraphQL 中,我们可以使用指令 @underEachArrayItem 遍历数组元素,并将每个值传递给链中的下一个指令,本例中为 @strUpperCase:
query PrintUserRolesAsUppercase($userID: ID!)
{
user(by: { id: $userID }) {
roles
@underEachArrayItem
@strUpperCase
}
}这通过可组合指令得以实现。
批量 CRUD 操作
CRUD 代表创建(Create)、读取(Read)、更新(Update)和删除(Delete),这些是我们对资源(文章、用户等)执行的操作。
PHP 中的批量读取如下:
function getPostTitles()
{
$posts = getPosts();
foreach ($posts as $post) {
echo $post->title;
}
}这个用例在 GraphQL 中可以自然地满足:
query GetPostTitles
{
posts {
title
}
}PHP 中的批量更新如下:
function updatePostTitlesAsUppercase()
{
$posts = getPosts();
foreach ($posts as $post) {
$post->update(['title' => strtoupper($post->title)]);
}
}在 GraphQL 中执行批量更新通常通过创建一个专用的 mutation updatePosts 来支持,该 mutation 接收所有文章的数据。
我不喜欢这种方式,因为它实际上使 schema 中的 mutation 数量翻倍(一个用于修改单个资源,一个用于修改多个资源),并且我们需要同时维护两者的逻辑:
updatePost+updatePostscreatePost+createPosts- 等等
在我看来,更优雅的方式是使用嵌套 mutation,其中 mutation Post.update 被应用于每个被查询的资源:
mutation UpdatePostTitlesAsUppercase
{
posts {
title
ucTitle: _strUpperCase(text: $__title)
update(
input: { title: $__ucTitle }
) {
status
post {
title
}
}
}
}同样的方式也适用于删除资源:
function deletePosts()
{
$posts = getPosts();
foreach ($posts as $post) {
$post->delete();
}
}GraphQL 的写法:
mutation DeletePosts
{
posts {
delete {
status
}
}
}对于创建,由于资源尚不存在,我们不传入资源;而是提供一个包含所有待创建资源数据输入的数组:
function createPosts()
{
$postDataItems = [
[
'title' => 'First title',
'content' => 'First content',
],
[
'title' => 'Second title',
'content' => 'Second content',
],
];
foreach ($postDataItems as $postDataItem) {
$post = new Post($postDataItem['title'], $postDataItem['content']);
$post->save();
}
}使用单个 createPost mutation 在 GraphQL 中批量创建文章稍微复杂一些,但依然可以实现。
思路是遍历数据输入数组,将每项赋给动态变量 $input,然后传入该输入执行 createPost mutation。最后,将已创建文章的结果 ID 收集到动态变量 $createdPostIDs 中,并获取它们的数据:
mutation CreatePosts
@depends(on: "GetPostsAndExportData")
{
createdPostIDs: _echo(value: [
{
title: "First title",
content: "First content"
},
{
title: "Second title",
content: "Second content"
},
])
@underEachArrayItem(
passValueOnwardsAs: "input"
)
@applyField(
name: "createPost"
arguments: {
input: $input
},
setResultInResponse: true
)
@export(as: "createdPostIDs")
}
query RetrieveCreatedPosts
@depends(on: "CreatePosts")
{
createdPosts: posts(
filter: {
ids: $createdPostIDs,
}
) {
title
content
}
}发送 HTTP 请求(及其他函数)
向某个 Web 服务器发送 HTTP 请求,可以通过 PHP 中的专用函数实现,例如 file_get_contents 或 curl_exec。
使用 file_get_contents:
$xml = file_get_contents("http://www.example.com/file.xml");在 GraphQL 中,执行 HTTP 请求的逻辑可以通过功能性字段来满足,例如 _sendHTTPRequest:
query {
_sendHTTPRequest(input: {
url: "http://www.example.com/file.xml",
method: GET
}) {
xml: body
}
}同样的思路适用于任何功能。
例如,在 PHP 中访问常量的值如下:
$mailchimpUsername = constant('MAILCHIMP_API_CREDENTIALS_USERNAME');我们可以在 GraphQL 中实现对应的功能性字段:
{
mailchimpUsername: _env(name: "MAILCHIMP_API_CREDENTIALS_USERNAME")
}仅使用 GraphQL 解决挑战
借助我们刚刚介绍的所有编程语言特性,我们现在能够仅使用 GraphQL 来解决之前提出的问题:
- 创建一个 Webhook,每当有新用户注册某服务时被该服务调用;用户可能订阅了新闻邮件(通过 Webhook 载荷中的
marketing_optin字段标识);如果订阅了,Webhook 必须将用户的电子邮件地址(Webhook 载荷中的email字段)注册到 Mailchimp 列表中。
解决方案是使用 GraphQL 持久化 Query 作为 Webhook,并执行以下 Query:
query HasSubscribedToNewsletter {
hasSubscriberOptIn: _httpRequestHasParam(name: "marketing_optin")
subscriberOptIn: _httpRequestStringParam(name: "marketing_optin")
isNotSubscriberOptInNAValue: _notEquals(value1: $__subscriberOptIn, value2: "NA")
subscribedToNewsletter: _and(values: [$__hasSubscriberOptIn, $__isNotSubscriberOptInNAValue])
@export(as: "subscribedToNewsletter")
}
query MaybeCreateContactOnMailchimp
@depends(on: "HasSubscribedToNewsletter")
@include(if: $subscribedToNewsletter)
{
subscriberEmail: _httpRequestStringParam(name: "email")
mailchimpUsername: _env(name: "MAILCHIMP_API_CREDENTIALS_USERNAME")
mailchimpPassword: _env(name: "MAILCHIMP_API_CREDENTIALS_PASSWORD")
mailchimpListMembersJSONObject: _sendJSONObjectItemHTTPRequest(input: {
url: "https://us7.api.mailchimp.com/3.0/lists/{listCode}/members",
method: POST,
options: {
auth: {
username: $__mailchimpUsername,
password: $__mailchimpPassword
},
json: {
email_address: $__subscriberEmail,
status: "subscribed"
}
}
})
}在这个解决方案中,MaybeCreateContactOnMailchimp 操作(向 Mailchimp API 发起 HTTP 请求)将根据 marketing_optin 字段的值有条件地执行。
(请阅读博客文章 👨🏻🏫 GraphQL query to automatically send the newsletter subscribers from InstaWP to Mailchimp,了解该 Query 的工作原理。)
GraphQL 比你想象的更强大!
GraphQL 的用途远不止于获取和修改数据……适配数据、动态修改输出、为不同上下文定制内容、用寥寥几行代码创建 API 网关,以及更多。
通过支持编程语言特性,我们可以仅使用 GraphQL 解决上述挑战,无需再部署一个配套的客户端。这样就简化了应用栈:更少的活动部件、更低的复杂度、更少需要调试的代码、更少需要处理的技术。
GraphQL 真棒 🤘