博客

👨🏻‍💻 GraphQL 作为(某种意义上的)编程语言

Leonardo Losoviz
作者:Leonardo Losoviz ·

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 时,将首先执行 PrintPostTitlePrintPostContent 这两个 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"
  ])
{
  # ...
}

现在,只有当 $addContenttrue 时,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 指令,RetrieveContentForAdminUserRetrieveContentForNonAdminUser 操作互相排斥。

迭代数组

假设我们想遍历数组中的元素,并将这些值转换为大写:

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 + updatePosts
  • createPost + 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_contentscurl_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 真棒 🤘


订阅我们的新闻通讯

及时了解 Gato GraphQL 的所有更新。