基于PHP组件实现高性能GraphQL服务器架构
GraphQL作为一种先进的API查询语言,其核心优势在于能够精准地满足客户端的数据需求,实现一次查询即可获取渲染组件所需的所有数据。与传统的REST API需要多次往返不同端点获取分散数据的方式相比,GraphQL显著提升了效率,尤其对于移动设备而言,其优势更为明显。尽管GraphQL(Graph Query Language)的命名暗示了其基于图数据模型,但服务器端实现并不强制要求使用图作为数据结构,图更多是一种心理模型而非实际实现。
GraphQL项目在其官方网站graphql.org上明确指出:图是模拟现实世界现象的强大工具,因其与我们的自然心理模型和口头描述高度相似。通过GraphQL,开发者可以通过定义模式将业务领域建模为图形,在架构中定义不同节点及其连接关系,在客户端形成类似面向对象编程的模式,而在服务器端则可以自由搭配任何后端系统,无论是新系统还是旧系统。这一灵活性是GraphQL的重要优势,因为处理图或树(作为图的子集)往往涉及复杂的算法,可能导致查询时间呈指数或对数级增长。
本文将深入探讨PoP在PHP GraphQL中的GraphQL服务器架构设计,该设计以组件作为核心数据结构而非图。服务器名称中的PoP源自组件库,该库基于组件模型在PHP中构建。全文分为五个部分,系统阐述:组件的基本概念、PoP的工作原理、组件在PoP中的定义方式、组件与GraphQL的天然契合度,以及使用组件解析GraphQL查询的性能优势。
### 1. 什么是组件
每个网页的布局都可以通过组件来表示。组件是一组代码(如HTML、JavaScript和CSS)的集合,共同构建一个自治实体,既能封装其他组件形成复杂结构,也能被其他组件封装。组件的用途多样,从简单的链接、按钮到复杂的轮播、拖放图像上传器等。通过组件构建站点类似于玩乐高,简单组件(如链接、按钮、头像)被组合成更复杂的结构(如小工具、部分、侧边栏、菜单),最终形成完整的网页。页面本身也是一个组件,如方框所示,包裹着其他组件。组件可以在客户端(如Vue、React或Bootstrap、Material-UI等库)和服务器端(任何编程语言)实现。
### 2. PoP的工作原理
PoP描述了一种基于服务器端组件模型的架构,并通过组件模型库在PHP中实现。术语“组件”和“模块”在此可互换使用。组件层次结构是指所有模块相互包裹的关系,从最顶层的模块到最内层的模块,这种关系可以通过服务器端的关联数组(key=>property)表示,每个模块将其名称作为key,内部模块作为property“modules”。PHP数组中的数据也可直接在客户端使用,编码为JSON对象。
组件层次结构如下所示:
“`php
$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和其他属性),分别存储在条目modulesettings和moduledata中:
“`php
$componentHierarchyData = [
“modulesettings” => [
‘module-level0’ => [
“configuration” => […],
“modules” => [
‘module-level1’ => [
“configuration” => […],
“modules” => [
‘module-level11’ => […],
‘module-level12’ => [
“configuration” => […],
“modules” => [
‘module-level121’ => […],
],
],
],
],
‘module-level2’ => [
“configuration” => […],
“modules” => [
‘module-level21’ => […],
],
],
],
],
],
“moduledata” => [
‘module-level0’ => [
“dbobjectids” => […],
“modules” => [
‘module-level1’ => [
“dbobjectids” => […],
“modules” => [
‘module-level11’ => […],
‘module-level12’ => [
“dbobjectids” => […],
“modules” => [
‘module-level121’ => […],
],
],
],
],
‘module-level2’ => [
“dbobjectids” => […],
“modules” => [
‘module-level21’ => […],
],
],
],
],
],
];
“`
数据库对象数据不是放在每个模块下,而是放在名为databases的共享部分下,以避免在两个或多个不同模块从数据库中获取相同对象时重复信息。此外,该库以关系方式表示数据库对象数据,以避免当两个或多个不同的数据库对象与一个共同的对象相关时(例如两个具有相同作者的文章)的信息重复。换句话说,数据库对象数据是标准化的。结构是一个字典,首先组织在每个对象类型下,然后是对象ID,可以从中获取对象属性:
“`php
$componentHierarchyData = [
…,
“databases” => [
“dbobject_type” => [
“dbobject_id” => [
“property” => …,
…,
],
…,
],
…,
],
];
“`
例如,下面的对象包含一个带有两个模块的组件层次结构”page”->”post-feed”,其中模块”post-feed”获取博客文章。请注意以下事项:每个模块都知道哪些是其从属性dbobjectids(ID4和9博客文章)中查询的对象;每个模块从属性中知道其查询对象的对象类型dbkeys(每个文章的数据都在下面找到”posts”,文章的作者数据,对应于在文章属性下给出的ID的作者,在下面”author”找到”users”);因为数据库对象数据是关系型的,所以属性”author”包含作者对象的ID,而不是直接打印作者数据:
“`php
$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”和”content”:
一些模块加载数据库对象,其他模块加载属性
因此,沿着组件层次结构,“数据加载”模块将负责加载查询的对象(在这种情况下是加载单个文章的模块),其后代模块将定义需要来自DB对象的哪些属性(”title”和”content”, 在这种情况下)。可以通过遍历组件层次结构来获取DB对象所需的所有属性:从数据加载模块开始,PoP一直迭代其所有后代模块,直到到达新的数据加载模块,或者直到树的末尾;在每一层,它获取所有需要的属性,然后将所有属性合并在一起并从数据库中查询它们,所有这些都只需要一次。因为数据库对象数据是以关系方式检索的,那么我们也可以在数据库对象本身之间的关系中应用这种策略。
考虑下图:
从对象类型”post”开始,向下移动组件层次结构,我们需要将数据库对象类型转换为”user”和”comment”,分别对应于文章的作者和每个文章的评论,然后,对于每个评论,它必须再次更改对象类型”user”以对应评论的作者。从数据库对象转移到关系对象就是我所说的“切换域”。
将数据库对象从一个域更改为另一个域
遍历组件层次结构,PoP知道它何时切换域并适当地获取关系对象数据。
### 3. PoP中如何定义组件
模块属性(配置值、要获取的数据库数据等)和子模块是通过ModuleProcessor对象逐模块定义的,PoP从处理所有相关模块的所有ModuleProcessor创建组件层次结构。类似于React应用程序(我们必须指出在哪个组件上渲染),PoP中的组件模型必须有一个入口模块。从它开始,PoP将遍历组件层次结构中的所有模块,从相应的ModuleProcessor中获取每个模块的属性,并创建包含所有模块所有属性的嵌套关联数组。
当一个组件定义一个后代组件时,它通过一个包含2个部分的数组来引用它:PHP类 组件名称
这是因为组件通常共享属性。例如,组件POST_THUMBNAIL_LARGE和POST_THUMBNAIL_SMALL将共享大多数属性,但缩略图的大小除外。然后,将所有相似的组件分组到同一个PHP类下,并使用switch语句来识别请求的模块并返回相应的属性,这是有意义的。
ModuleProcessor要放置在不同页面上的文章小工具组件如下所示:
“`php
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类来完成的,这些类定义了必须由一些实例化类实现的占位符函数:
“`php
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类可以扩展抽象类,并定义自己的属性:
“`php
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(或任何其他基于组件的库),我们将首先识别组件。在这种情况下,我们将有一个外部组件(红色),它wrap一个组件(蓝色),它本身wrap一个组件(绿色):
识别小工具中的组件
伪代码如下所示:
“`graphql
Country: {country} {foreach films as film} {/foreach} Title: {title} Pic: {thumbnail} {foreach actors as actor} {/foreach} Name: {name} Photo: {avatar}
“`
然后我们确定每个组件需要哪些数据。对于,我们需要name,avatar和country。对于我们需要thumbnail和title。对于我们需要name和avatar:
识别每个组件的数据属性
我们构建了GraphQL查询来获取所需的数据:
“`graphql
query {
featuredDirector {
name country avatar films {
title thumbnail actors {
name avatar
}
}
}
}
“`
可以理解,组件层次结构的形状和GraphQL查询之间存在直接关系。事实上,一个GraphQL查询甚至可以被认为是一个组件层次结构的表示。
使用服务器端组件解析GraphQL查询
由于GraphQL查询具有相同的组件层次结构,PoP 将查询转换为其等效的组件层次结构,使用其为组件获取数据的方法对其进行解析,最后重新创建查询的形状以在响应中发送数据。让我们看看这是如何工作的。
为了处理数据,PoP将GraphQL类型转换为组件:Director => Film => Actor,并使用使用它们在查询中出现的顺序,PoP创建了一个具有相同元素的虚拟组件层次结构:根组件Director,wrap组件Film,wrap组件Actor。从现在开始,谈论GraphQL类型或PoP组件不再重要。
为了加载它们的数据,PoP在“迭代”中处理它们,在自己的迭代中检索每种类型的对象数据,如下所示:
处理迭代中的类型
PoP的数据加载引擎实现了以下伪算法来加载数据:
“`plaintext
准备: 有一个空队列存储必须从数据库中获取的对象的ID列表,按类型组织(每个条目将是[type => list of IDs]:)
检索特色导演对象的ID,并将其放在队列中的Director类型下
循环直到队列中没有更多条目:
从队列中获取第一个条目:ID的类型和列表(例如:Director和[2]),并将此条目从队列中移除
对数据库执行单个查询以检索具有这些ID的该类型的所有对象
如果该类型具有关系字段(例如:Director类型具有films类型的关系字段Film),则从当前迭代中检索到的所有对象中收集这些字段的所有ID(例如:来自Director类型的所有对象的films中的所有ID,并将队列中对应类型下的这些ID(例如: Film类型下的ID [3, 8])。
“`
在迭代结束时,我们将加载所有类型的所有对象数据,如下所示:
处理迭代中的类型
请注意,在队列中处理该类型之前,如何收集该类型的所有ID。例如,如果我们向类型Director添加一个关系字段preferredActors,这些ID将添加到类型Actor下的队列中,并将与类型Film中的字段actors的ID一起处理:
处理迭代中的类型
但是,如果一个类型已被处理,然后我们需要从该类型加载更多数据,那么它就是该类型的新迭代。例如,将关系字段preferredDirector添加到Author类型中,将使类型Director再次添加到队列中:
迭代重复的类型
还要注意,这里我们可以使用缓存机制:在类型Director的第二次迭代中,不会再次检索ID为2的对象,因为它在第一次迭代中已经检索到,因此可以从缓存中获取。
现在我们已经获取了所有对象数据,我们需要将其塑造成预期的响应,镜像GraphQL查询。目前,数据被组织为关系数据库:
Director类型表:
| ID | NAME | COUNTRY | AVATAR | FILMS |
|—-|———|———|—————–|—————-|
| 2 | George Lucas | USA | george-lucas.jpg | [3, 8] |
Film类型表:
| ID | TITLE | THUMBNAIL | ACTORS |
|—-|———————|———–|—————–|
| 3 | The Phantom Menace | episode-1.jpg | [4, 6] |
| 8 | Attack of the Clones | episode-2.jpg | [6, 7] |
Actor类型表:
| ID | NAME | AVATAR |
|—-|—————-|—————|
| 4 | Ewan McGregor | mcgregor.jpg |
| 6 | Nathalie Portman | portman.jpg |
| 7 | Hayden Christensen | christensen.jpg |
在这个阶段,PoP将所有数据组织为表格,以及每种类型如何相互关联(即Director 通过films字段引用Film,Film 通过actors引用Actor)。然后,通过从根迭代组件层次结构、导航关系并从关系表中检索相应的对象,PoP将从GraphQL查询中生成树形:
树形响应
最后,将数据打印到输出中会产生与GraphQ查询形状相同的响应:
“`json
{
“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查询,这比使用图或树所期望的指数或对数时间复杂度要好。