使用 PHP 组件实现 GraphQL 服务器

GraphQL 是一种用于 API 的查询语言,它使客户能够准确地询问他们需要什么数据并准确地接收这些数据,不多也不少.这样,单个查询就可以获取渲染组件所需的所有数据.

(相比之下,REST API 必须触发多次往返才能从不同端点的多个资源中获取数据,这可能会变得非常缓慢,尤其是在移动设备上.)

尽管 GraphQL(意为"图查询语言")使用图数据模型来表示数据,但 GraphQL 服务器不一定需要使用图作为数据结构来解析查询,而是可以使用任何数据结构那个愿望.该图只是一个心理模型,而不是实际实现.

GraphQL 项目在其网站 graphql.org(重点是我的)上声明,对此表示认可:

图是模拟许多现实世界现象的强大工具,因为它们类似于我们自然的心理模型和对底层过程的口头描述.使用 GraphQL,您可以通过定义架构将您的业务领域建模为图表;在您的架构中,您定义不同类型的节点以及它们如何相互连接/关联.在客户端,这会创建一个类似于面向对象编程的模式:引用其他类型的类型.在服务器上,由于 GraphQL 只定义了接口,您可以自由地将其与任何后端(新的或旧的!)一起使用..

这是个好消息,因为处理图或树(图的子集)不是微不足道的,并且可能导致解析查询的指数或对数时间复杂度(即解析查询所需的时间可能为查询的每个新输入增加几个数量级).

在本文中,我将描述 PHP 中 GraphQL 服务器的架构设计 PoP 的 GraphQL,它使用组件作为数据结构而不是图.该服务器的名称来自 PoP,该库用于在 PHP 中构建它所基于的组件.(我是这两个项目的作者.)

本文分为5个部分,解释:

  1. 什么是组件
  2. 如何PoP 的工作原理
  3. PoP 中如何定义组件
  4. 组件如何自然地适用于 GraphQL
  5. 使用组件解决 GraphQL 查询的性能

让我们开始吧.

1.什么是组件

每个网页的布局都可以使用组件来表示.一个组件只是一组代码(如 HTML、JavaScript 和 CSS)放在一起创建一个自治实体,它可以包装其他组件以创建更复杂的结构,并且本身也被其他组件包裹.每个组件都有一个用途,范围可以从非常基本的东西(例如链接或按钮)到非常复杂的东西(例如轮播或拖放图片上传器).

通过组件构建网站类似于玩 LEGO.例如,在下图中的网页中,简单的组件(链接、按钮、头像)被组合起来创建更复杂的结构(小部件、部分、侧边栏、菜单),一直到顶部,直到我们获得网页:

页面是一个组件包裹组件包裹组件,如方块所示

组件可以在客户端(例如 JS 库 Vue 和 React,或 CSS 组件库 Bootstrap 和 Material-UI)和服务器端以任何语言实现.

2.PoP 的工作原理

PoP 描述了一种基于服务器的架构-side组件模型,通过组件在PHP中实现-model 库.

在以下部分中,术语"组件"和"模块"可互换使用.

组件层次结构

所有模块相互包装的关系,从最顶层的模块一直到最后一层,称为组件层次结构.这种关系可以通过服务器端的关联数组(key => property 的数组)来表达,其中每个模块将其名称声明为键属性,并将其内部模块声明为属性 "modules".

PHP数组中的数据也可以直接在客户端使用,编码为JSON对象.

组件层次结构如下所示:

$componentHierarchy = [
  'module-level0' => [
    "modules" => [
      'module-level1' => [
        "modules" => [
          'module-level11' => [
            "modules" => [...]
          ],
          'module-level12' => [
            "modules" => [
              'module-level121' => [
                "modules" => [...]
              ]
            ]
          ]
        ]
      ],
      'module-level2' => [
        "modules" => [
          'module-level21' => [
            "modules" => [...]
          ]
        ]
      ]
    ]
  ]
]

模块之间的关系严格按照自上而下的方式定义:一个模块包装了其他模块并且知道它们是谁,但它不知道也不关心哪些模块包装了他.

例如,在上面的组件层次结构中,模块 'module-level1' 知道它包装了模块 'module-level11''module-level12',并且,传递性地,它也知道它包装了 'module-level121';但是模块 'module-level11' 不在乎谁在包装他,因此不知道 'module-level1'.

在基于组件的结构中,我们添加了每个模块所需的实际信息,这些信息分为设置(例如配置值和其他属性)和数据(例如查询的数据库对象的 ID 和其他属性) ),并相应地放置在条目 modulesettingsmoduledata 下:

$componentHierarchyData = [
  "modulesettings" => [
    'module-level0' => [
      "configuration" => [...],
     ...,
      "modules" => [
        'module-level1' => [
          "configuration" => [...],
         ...,
          "modules" => [
            'module-level11' => [
             ...children...
            ],
            'module-level12' => [
              "configuration" => [...],
             ...,
              "modules" => [
                'module-level121' => [
                 ...children...
                ]
              ]
            ]
          ]
        ],
        'module-level2' => [
          "configuration" => [...],
         ...,
          "modules" => [
            'module-level21' => [
             ...children...
            ]
          ]
        ]
      ]
    ]
  ],
  "moduledata" => [
    'module-level0' => [
      "dbobjectids" => [...],
     ...,
      "modules" => [
        'module-level1' => [
          "dbobjectids" => [...],
         ...,
          "modules" => [
            'module-level11' => [
             ...children...
            ],
            'module-level12' => [
              "dbobjectids" => [...],
             ...,
              "modules" => [
                'module-level121' => [
                 ...children...
                ]
              ]
            ]
          ]
        ],
        'module-level2' => [
          "dbobjectids" => [...],
         ...,
          "modules" => [
            'module-level21' => [
             ...children...
            ]
          ]
        ]
      ]
    ]
  ]
]

接下来,将数据库对象数据添加到组件层次结构中.此信息不是放在每个模块下,而是放在名为 databases 的共享部分下,以避免在 2 个或多个不同模块从数据库中获取相同对象时出现重复信息.

此外,库以关系方式表示数据库对象数据,以避免当 2 个或多个不同的数据库对象与一个公共对象(例如 2 个具有相同作者的帖子)相关时出现重复信息.

换句话说,数据库对象数据是规范化的.该结构是一个字典,首先按照每个对象类型组织,然后按照对象 ID 组织,我们可以从中获取对象属性:

$componentHierarchyData = [
 ...
  "databases" => [
    "dbobject_type" => [
      "dbobject_id" => [
        "property" =>...,
       ...
      ],
     ...
    ],
   ...
  ]
]

例如,下面的对象包含一个具有两个模块的组件层次结构,"page" => "post-feed",其中模块 "post-feed" 获取博客文章.请注意以下事项:

  • 每个模块通过属性 dbobjectids(博客文章的 ID 49)知道哪些是它的查询对象
  • 每个模块都知道其查询对象的对象类型来自属性 dbkeys(每个帖子的数据在 "posts" 下,帖子的作者数据,对应于在帖子属性 "author" 下给出的 ID 的作者,在 "users" 下找到):
  • 因为数据库对象数据是关系型的,属性"author"包含作者对象的ID,而不是直接打印作者数据
$componentHierarchyData = [
  "moduledata" => [
    'page' => [
      "modules" => [
        'post-feed' => [
          "dbobjectids": [4, 9]
        ]
      ]
    ]
  ],
  "modulesettings" => [
    'page' => [
      "modules" => [
        'post-feed' => [
          "dbkeys" => [
            'id' => "posts",
            'author' => "users"
          ]
        ]
      ]
    ]
  ],
  "databases" => [
    'posts' => [
      4 => [
        'title' => "Hello World!",
        'author' => 7
      ],
      9 => [
        'title' => "Everything fine?",
        'author' => 7
      ]
    ],
    'users' => [
      7 => [
        'name' => "Leo"
      ]
    ]
  ]
]

数据加载

当模块显示来自数据库对象的属性时,该模块可能不知道或不关心它是什么对象;它只关心定义加载对象的哪些属性是必需的.

例如,考虑下图:一个模块从数据库加载一个对象(在这种情况下,一个单独的帖子),然后它的后代模块将显示该对象的某些属性,例如 "title" 和 :

虽然一些模块加载数据库对象,但其他模块加载属性

因此,在组件层次结构中,"数据加载"模块将负责加载查询的对象(在本例中为加载单个帖子的模块),其后代模块将定义 DB 对象的哪些属性必需(在本例中为 "title""content").

可以通过遍历组件层次结构来获取 DB 对象的所有必需属性:从数据加载模块开始,PoP 一直向下迭代其所有后代模块,直到到达新的数据加载模块,或者直到结束树;在每一层,它获取所有需要的属性,然后将所有属性合并在一起并从数据库中查询它们,所有这些只需要一次.

因为数据库对象数据是以关系方式检索的,所以我们也可以在数据库对象本身之间的关系中应用这种策略.

考虑下图:从对象类型 "post" 开始,向下移动组件层次结构,我们需要将数据库对象类型移动到 "user""comment",对应于帖子的作者和每个帖子的评论,然后,对于每个评论,它必须再次将对象类型更改为与评论作者对应的 "user".从数据库对象转移到关系对象就是我所说的"切换域".

切换到新域后,从组件层次结构的该级别向下,所有必需的属性都将服从新域:属性 "name" 是从表示帖子作者的 "user" 对象中获取的,"content"来自代表每条帖子评论的 "comment" 对象,然后来自代表每条评论作者的 "user" 对象的 "name" :

将数据库对象从一个域更改为另一个域

遍历组件层次结构,PoP 知道它何时切换域,并适当地获取关系对象数据.

3.PoP 中如何定义组件

模块属性(配置值、要获取的数据库数据等)和后代模块通过 ModuleProcessor 对象在一个模块一个模块的基础上定义,PoP 从所有 ModuleProcessor 处理所有涉及的内容创建组件层次结构模块.

类似于 React 应用程序(我们必须指明在 <div id="root"></div> 上呈现哪个组件),PoP 中的组件模型必须有一个入口模块.从它开始,PoP 将遍历组件层次结构中的所有模块,从相应的 ModuleProcessor 中获取每个模块的属性,并为所有模块创建具有所有属性的嵌套关联数组.

当一个组件定义一个后代组件时,它通过一个由 2 个部分组成的数组来引用它:

  1. PHP 类
  2. 组件名称

这是因为组件通常共享属性.例如,组件 POST_THUMBNAIL_LARGEPOST_THUMBNAIL_SMALL 将共享大多数属性,缩略图的大小除外.然后,将所有相似的组件归为同一个 PHP 类,并使用 switch 语句来识别请求的模块并返回相应的属性.

用于放置在不同页面上的帖子小部件组件的 ModuleProcessor 如下所示:

class PostWidgetModuleProcessor extends AbstractModuleProcessor {

  const POST_WIDGET_HOMEPAGE = 'post-widget-homepage';
  const POST_WIDGET_AUTHORPAGE = 'post-widget-authorpage';

  function getSubmodulesToProcess() {
  
    return [
      self::POST_WIDGET_HOMEPAGE,
      self::POST_WIDGET_AUTHORPAGE,
    ];
  }

  function getSubmodules($module): array 
  {
    $ret = [];

    switch ($module[1]) {      
      case self::POST_WIDGET_HOMEPAGE:
      case self::POST_WIDGET_AUTHORPAGE:
        $ret[] = [
          UserLayoutModuleProcessor::class,
          UserLayoutModuleProcessor::POST_THUMB
        ];
        $ret[] = [
          UserLayoutModuleProcessor::class,
          UserLayoutModuleProcessor::POST_TITLE
        ];
        break;
    }
    switch ($module[1]) {      
      case self::POST_WIDGET_HOMEPAGE:
        $ret[] = [
          UserLayoutModuleProcessor::class,
          UserLayoutModuleProcessor::POST_DATE
        ];
        break;
    }

    return $ret;
  }

  function getImmutableConfiguration($module, &$props) 
  {
    $ret = [];

    switch ($module[1]) {
      case self::POST_WIDGET_HOMEPAGE:        
        $ret['description'] = __('Latest posts', 'my-domain');
        $ret['showmore'] = $this->getProp($module, $props, 'showmore');
        $ret['class'] = $this->getProp($module, $props, 'class');
        break;

      case self::POST_WIDGET_AUTHORPAGE:        
        $ret['description'] = __('Latest posts by the author', 'my-domain');
        $ret['showmore'] = false;
        $ret['class'] = 'text-center';
        break;
    }

    return $ret;
  }
  
  function initModelProps($module, &$props) 
  {
    switch ($module[1]) {
      case self::POST_WIDGET_HOMEPAGE:
        $this->setProp($module, $props, 'showmore', false);
        $this->appendProp($module, $props, 'class', 'text-center');
        break;
    }

    parent::initModelProps($module, $props);
  }
 //...
}

创建可重用组件是通过制作抽象的 ModuleProcessor 类来完成的,这些类定义了必须由某个实例化类实现的占位符函数:

abstract class PostWidgetLayoutAbstractModuleProcessor extends AbstractModuleProcessor
{
  function getSubmodules($module): array
  {  
    $ret = [
      $this->getContentModule($module),
    ];

    if ($thumbnail_module = $this->getThumbnailModule($module)) 
    {
      $ret[] = $thumbnail_module;
    }

    if ($aftercontent_modules = $this->getAfterContentModules($module)) 
    {
      $ret = array_merge(
        $ret,
        $aftercontent_modules
      );
    }

    return $ret;
  }

  abstract protected function getContentModule($module): array;

  protected function getThumbnailModule($module): ?array 
  {
   //Default value (overridable)
    return [self::class, self::THUMBNAIL_LAYOUT];
  }

  protected function getAfterContentModules($module): array 
  {
    return [];
  }

  function getImmutableConfiguration($module, &$props): array 
  {
    return [
      'description' => $this->getDescription(),
    ];
  }

  protected function getDescription($module): string
  {
    return '';
  }
}

自定义ModuleProcessor类然后可以扩展抽象类,并定义自己的属性:

class PostLayoutModuleProcessor extends AbstractPostLayoutModuleProcessor {

  const POST_CONTENT = 'post-content'
  const POST_EXCERPT = 'post-excerpt'
  const POST_THUMBNAIL_LARGE = 'post-thumbnail-large'
  const POST_THUMBNAIL_MEDIUM = 'post-thumbnail-medium'
  const POST_SHARE = 'post-share'

  function getSubmodulesToProcess() {
  
    return [
      self::POST_CONTENT,
      self::POST_EXCERPT,
      self::POST_THUMBNAIL_LARGE,
      self::POST_THUMBNAIL_MEDIUM,
      self::POST_SHARE,
    ];
  }

}

class PostWidgetLayoutModuleProcessor extends AbstractPostWidgetLayoutModuleProcessor
{
  protected function getContentModule($module): ?array 
  {
    switch ($module[1]) 
    {
      case self::POST_WIDGET_HOMEPAGE_LARGE:
        return [
          PostLayoutModuleProcessor::class,
          PostLayoutModuleProcessor::POST_CONTENT
        ];

      case self::POST_WIDGET_HOMEPAGE_MEDIUM:
      case self::POST_WIDGET_HOMEPAGE_SMALL:
        return [
          PostLayoutModuleProcessor::class,
          PostLayoutModuleProcessor::POST_EXCERPT
        ];
    }

    return parent::getContentModule($module);
  }

  protected function getThumbnailModule($module): ?array 
  {
    switch ($module[1]) 
    {
      case self::POST_WIDGET_HOMEPAGE_LARGE:
        return [
          PostLayoutModuleProcessor::class,
          PostLayoutModuleProcessor::POST_THUMBNAIL_LARGE
        ];

      case self::POST_WIDGET_HOMEPAGE_MEDIUM:
        return [
          PostLayoutModuleProcessor::class,
          PostLayoutModuleProcessor::POST_THUMBNAIL_MEDIUM
        ];
    }

    return parent::getThumbnailModule($module);
  }

  protected function getAfterContentModules($module): array 
  {
    $ret = [];

    switch ($module[1]) 
    {
      case self::POST_WIDGET_HOMEPAGE_LARGE:
        $ret[] = [
          PostLayoutModuleProcessor::class,
          PostLayoutModuleProcessor::POST_SHARE
        ];
        break
    }

    return $ret;
  }

  protected function getDescription($module): string
  {
    return __('These are my blog posts', 'my-domain');
  }
}

4.组件如何自然地适用于 GraphQL

组件模型可以自然地映射树形 GraphQL 查询,使其成为实现 GraphQL 服务器的理想架构.

PoP 的 GraphQL 有 实现了将 GraphQL 查询转换为其相应组件层次结构所需的 ModuleProcessor,并使用 PoP 数据加载引擎解析它.

这就是此解决方案起作用的原因和方式.

将客户端组件映射到 GraphQL 查询

GraphQL 查询可以使用 PoP 的组件层次结构来表示,其中每个对象类型代表一个组件,从一个对象类型到另一个对象类型的每个关系字段代表一个组件包装另一个组件.

让我们通过一个例子来看看这是怎么回事.假设我们要构建以下"特色导演"小部件:

特色导演小部件

使用 Vue 或 React(或任何其他基于组件的库),我们将首先识别组件.在这种情况下,我们将有一个外部组件 <FeaturedDirector>(红色),它包裹着一个组件 <Film>(蓝色),而它本身包裹着一个组件 <Actor>(绿色):

识别小部件中的组件

伪代码如下:

<!--Component: <FeaturedDirector>-->
<div>
  Country: {country}
  {foreach films as film}
    <Film film={film}/>
  {/foreach}
</div>

<!--Component: <Film>-->
<div>
  Title: {title}
  Pic: {thumbnail}
  {foreach actors as actor}
    <Actor actor={actor}/>
  {/foreach}
</div>

<!--Component: <Actor>-->
<div>
  Name: {name}
  Photo: {avatar}
</div>

然后我们确定每个组件需要哪些数据.对于 <FeaturedDirector>,我们需要 nameavatarcountry.对于 <Film>,我们需要 thumbnailtitle.对于 <Actor>,我们需要 nameavatar:

识别每个组件的数据属性

然后我们构建我们的 GraphQL 查询来获取所需的数据:

query {
  featuredDirector {
    name
    country
    avatar
    films {
      title
      thumbnail
      actors {
        name
        avatar
      }
    }
  }
}

可以理解,组件层次结构的形状与 GraphQL 查询之间存在直接关系.事实上,GraphQL 查询甚至可以被视为组件层次结构的表示.

使用服务器端组件解析 GraphQL 查询

由于 GraphQL 查询具有相同形状的组件层次结构,PoP 将查询转换为其等效的组件层次结构,使用其为组件获取数据的方法解析它,最后重新创建查询的形状以发送数据在回复中.

让我们看看这是如何工作的.

为了处理数据,PoP 将 GraphQL 类型转换为组件:<FeaturedDirector> => Director, <Film> => Film, <Actor> => Actor,并使用它们在查询中出现的顺序,PoP 创建一个具有相同元素的虚拟组件层次结构:根组件 Director,它包裹组件 Film,包裹组件 Actor.

从现在开始,谈论 GraphQL 类型或 PoP 组件没有区别.

为了加载它们的数据,PoP 在"迭代"中处理它们,在它自己的迭代中检索每种类型的对象数据,如下所示:

处理迭代中的类型

PoP 的数据加载引擎实现了以下伪算法来加载数据:

准备:

  1. 有一个空队列存储必须从数据库中获取的对象的 ID 列表,按类型组织(每个条目将是:[type => list of IDs])
  2. 检索对象的 ID特色导演对象,并将其放在 Director

类型下的队列中

循环直到队列中没有更多条目:

  1. 从队列中获取第一个条目:ID 的类型和列表(例如:Director[2]),并将该条目从队列中移除
  2. 执行单个查询对数据库检索具有这些 ID 的该类型的所有对象
  3. 如果该类型具有关系字段(例如:类型 Director 具有类型 Film 的关系字段 films),则收集所有来自当前迭代中检索到的所有对象的这些字段的 ID(例如:Director 类型的所有对象的字段 films 中的所有 ID),并将这些 ID 放在相应类型下的队列中(例如:ID 在类型 Film 下).

在迭代结束时,我们将加载所有类型的所有对象数据,如下所示:

处理迭代中的类型

请注意一个类型的所有 ID 是如何收集的,直到该类型在队列中被处理.例如,如果我们将一个关系字段 preferredActors 添加到类型 Director 中,这些 ID 将添加到类型 Actor 下的队列中,并将与类型 actors 中的字段 ID 一起处理c64>:

处理迭代中的类型

然而,如果一个类型已经被处理,然后我们需要从该类型加载更多数据,那么它是对该类型的新迭代.例如,向 Author 类型添加一个关系字段 preferredDirector,将使类型 Director 再次添加到队列中:

迭代重复类型

还要注意,这里我们可以使用缓存机制:在类型 Director 的第二次迭代中,不会再次检索 ID 为 2 的对象,因为它已经在第一次迭代中检索到,因此可以从缓存.

既然我们已经获取了所有的对象数据,我们需要将其塑造成预期的响应,镜像 GraphQL 查询.目前,数据的组织方式与关系数据库一样:

Director 类型的表格:

ID name country avatar 电影
2 乔治卢卡斯 美国 乔治卢卡斯.jpg [3, 8]

Film 类型的表格:

ID title 缩略图 演员
3 幻影威胁 episode-1.jpg [4, 6]
8 克隆人的进攻 episode-2.jpg [6, 7]

Actor 类型的表格:

ID name 头像
4 伊万·麦格雷戈 mcgregor.jpg
6 娜塔莉·波特曼 portman.jpg
7 海登克里斯滕森 christensen.jpg

在这个阶段,PoP 将所有数据组织成表格,以及每种类型如何相互关联(即 Director 通过字段 films 引用 Film,通过字段 Film 引用 Actor c84>).然后,通过从根开始迭代组件层次结构,导航关系,并从关系表中检索相应的对象,PoP 将根据 GraphQL 查询生成树形:

树形响应

最后,将数据打印到输出中会产生与 GraphQL 查询形状相同的响应:

{
  data: {
    featuredDirector: {
      name: "George Lucas",
      country: "USA",
      avatar: "george-lucas.jpg",
      films: [
        { 
          title: "Star Wars: Episode I",
          thumbnail: "episode-1.jpg",
          actors: [
            {
              name: "Ewan McGregor",
              avatar: "mcgregor.jpg",
            },
            {
              name: "Natalie Portman",
              avatar: "portman.jpg",
            }
          ]
        },
        { 
          title: "Star Wars: Episode II",
          thumbnail: "episode-2.jpg",
          actors: [
            {
              name: "Natalie Portman",
              avatar: "portman.jpg",
            },
            {
              name: "Hayden Christensen",
              avatar: "christensen.jpg",
            }
          ]
        }
      ]
    }
  }
}

5.分析使用组件解析 GraphQL 查询的性能

让我们分析数据加载算法的大 O 表示法,以了解对数据库执行的查询数量如何随着输入数量的增长而增长,以确保该解决方案具有高性能.

PoP 的数据加载引擎在对应于每种类型的迭代中加载数据.到它开始迭代时,它已经拥有要获取的所有对象的所有 ID 的列表,因此它可以执行 1 个查询来获取相应对象的所有数据.然后,对数据库的查询数量将随着查询中涉及的类型数量线性增长.换句话说,时间复杂度是 O(n),其中 n 是查询中类型的数量(但是,如果一个类型被迭代多次,那么它必须多次添加到 n 中).

这个解决方案的性能非常好,肯定比处理图形时预期的指数复杂度或处理树时预期的对数复杂度要高.

结论

GraphQL 服务器不需要使用图来表示数据.在本文中,我们探索了 PoP 描述的架构,PoP 用 GraphQL 实现的架构,它基于组件并根据类型迭代加载数据.

通过这种方法,服务器可以解决具有线性时间复杂度的 GraphQL 查询,这比使用图形或树预期的指数或对数时间复杂度要好.

7

发表回复