博客

💁🏽‍♂️ 为何支持CMS无关性,Gato GraphQL被拆分为约90个包,以及这种方式的优缺点

Leonardo Losoviz
作者:Leonardo Losoviz ·

上周,我发表了文章💁🏻‍♀️ Gato GraphQL为何需要Monorepo,以及如何对其进行优化,解释了托管 Gato GraphQL 代码的 GatoGraphQL/GatoGraphQL 单体仓库如何高效管理插件代码库。

我在Reddit上分享了这篇文章,收到了如下评论

OP的文章以及其中链接的文章,读起来好像monorepo是切片面包以来最伟大的发明一样。

更有意思的文章应该解释:为什么你认为CMS无关性需要把所有东西拆分成各自独立的小包,以及为什么你认为200多个包中的每一个从一开始就需要放在各自独立的仓库里。

这是个很有意思的问题。因此我决定写这篇文章,对此做进一步的说明。

但首先,我会先讲两个相关话题:插件实际需要多少个包,以及为什么我声称底层GraphQL服务器是CMS无关的。

插件由多少个包构成

虽然我提到了200多个PHP包,但那是针对整个单体仓库而言的;对于插件本身,实际数量远少于此。

GatoGraphQL/GatoGraphQL单体仓库包含5个项目:

  1. PoP:服务端组件模型库(类似React,但面向后端)
  2. GraphQL by PoP:面向PHP的CMS无关GraphQL服务器
  3. Gato GraphQL
  4. 站点构建器(WIP)
  5. Wassup:基于站点构建器的网站主题(WIP)

将这些项目托管在单体仓库中,由于它们之间的相互依赖关系,工作得以简化:

  • GraphQL by PoP基于PoP
  • Gato GraphQL基于GraphQL by PoP
  • 站点构建器以组件模型库为引擎(类似Gatsby使用GraphQL)
  • Wassup基于站点构建器

就所有5个项目的代码而言,GatoGraphQL/GatoGraphQL 包含超过200个PHP包。就 Gato GraphQL 而言,"仅有"91个包。底层GraphQL服务器 GraphQL by PoP "仅有"98个包。

(Gato GraphQL插件所需包的数量少于其底层GraphQL服务器,是因为一些包,例如Google Translate @strTranslate 指令,尚未添加到插件中。)

GraphQL by PoP如何实现CMS无关性?与webonyx有何不同?

我一直说GraphQL by PoP是CMS无关的。但这究竟是什么意思呢?

顺便说一句,webonyx/graphql-php 也是CMS无关的。那么两者有何区别?

webonyx/graphql-php 是CMS无关的,意思是它是通过Composer分发的包,仅包含"原生"PHP代码。但它本身并不是一个完整的GraphQL服务器;相反,它是GraphQL规范的PHP实现,需要被嵌入到某个PHP GraphQL服务器中。

而实现了GraphQL服务器的框架,如LighthouseWPGraphQL,并不是CMS无关的。我们不能在WordPress上运行Lighthouse,也不能在Laravel上运行WPGraphQL。

正是在这个意义上,GraphQL by PoP是CMS无关的:它是"几乎完整"的GraphQL服务器,几乎可以在任何CMS或框架上运行,无论是Laravel、WordPress还是其他任何框架。(为简洁起见,以下凡提到"CMS",均指"CMS或框架"。)

要针对某个CMS将其最终完善,GraphQL服务器仍然需要通过相应的包提供该CMS的自定义代码。

下面我来回答评论中的问题。

为什么每个包需要放在各自独立的仓库中

因为Packagist(Composer的PHP包注册中心)要求提供仓库URL才能发布/分发包。

(顺便提一下,我上周同样发表的文章Hosting all your PHP packages together in a monorepo也讨论了这个问题。)

为什么CMS无关性需要将所有内容拆分为各自独立的小包

有几个原因。

让CMS注入自己的代码

使用100%相同的PHP代码创建一个在任何地方都能运行的GraphQL服务器是不可能的。

例如,要让某段代码能够修改其他地方某个变量的值,WordPress依赖于过滤器钩子,Symfony使用EventDispatcher组件,Laravel有其自己的事件与监听器系统。这3种不同方法的PHP代码也各不相同。

这就是将代码拆分为精细包的方式发挥作用的地方。事件与监听器的解决方案不再作为应用程序的一部分,而是通过包注入到应用程序中,该包将包含特定于CMS的代码。

要使其正常工作,每个功能必须拆分为2个包:

  • CMS无关包:包含所有业务逻辑,仅使用"原生"PHP代码。该包将包含CMS特定包需要满足的契约
  • CMS特定包:满足该CMS的契约

例如,GraphQL by PoP有一个hooks包,包含如下契约

interface HooksAPIInterface
{
  public function addFilter(string $tag, callable $function_to_add, int $priority = 10, int $accepted_args = 1): void;
  public function removeFilter(string $tag, callable $function_to_remove, int $priority = 10): bool;
  public function applyFilters(string $tag, mixed $value, mixed ...$args): mixed;
  public function addAction(string $tag, callable $function_to_add, int $priority = 10, int $accepted_args = 1): void;
  public function removeAction(string $tag, callable $function_to_remove, int $priority = 10): bool;
  public function doAction(string $tag, mixed ...$args): void;
}

然后,包hooks-wp满足WordPress的契约

class HooksAPI implements HooksAPIInterface
{
  public function addFilter(string $tag, callable $function_to_add, int $priority = 10, int $accepted_args = 1): void
  {
    \add_filter($tag, $function_to_add, $priority, $accepted_args);
  }
  public function removeFilter(string $tag, callable $function_to_remove, int $priority = 10): bool
  {
    return \remove_filter($tag, $function_to_remove, $priority);
  }
  public function applyFilters(string $tag, mixed $value, mixed ...$args): mixed
  {
    return \apply_filters($tag, $value, ...$args);
  }
  public function addAction(string $tag, callable $function_to_add, int $priority = 10, int $accepted_args = 1): void
  {
    \add_action($tag, $function_to_add, $priority, $accepted_args);
  }
  public function removeAction(string $tag, callable $function_to_remove, int $priority = 10): bool
  {
    return \remove_action($tag, $function_to_remove, $priority);
  }
  public function doAction(string $tag, mixed ...$args): void
  {
    \do_action($tag, ...$args);
  }
}

虽然钩子的概念来源于WordPress,但它也可以在其他CMS中使用(例如,通过使用事件和监听器来实现钩子)。因此,我们可以将hooks-wp替换为hooks-laravelhooks-symfonyhooks-drupalhooks-octobercms或其他任何实现,以使用各CMS特定的代码来满足契约。

允许CMS丢弃其无法支持的功能

并非所有CMS都能支持所有功能。例如,WordPress支持按某个meta_value条目对文章排序,但OctoberCMS不支持。

这就是为什么GraphQL by PoP包含metaquery包(在WordPress上通过metaquery-wp来满足)。WordPress的GraphQL服务器实现会包含这个包,但OctoberCMS的实现不会。

这种方式的优点

将包精细化拆分提供了一些优势。

将业务逻辑与CMS特定代码解耦

我们不必根据CMS的特性(编码方式、功能、限制等)来编写应用程序,而是可以对代码进行抽象,仅使用业务逻辑。

例如,要获取文章列表,应用程序可以调用CMS无关包posts中某个接口的getPosts方法。这样,无论底层CMS如何实现,文章都始终以相同的方式获取。

绕过技术债务,使用最新标准

沿用上面的例子,我们通过执行遵循PSR-4规范的getPosts方法来获取文章,而不是调用WordPress定义的get_posts

类似地,我们可以执行getCustomPost来获取自定义文章,而不是使用不够准确的get_post(这是WordPress技术债务的一部分)。

易于作用域限定

对WordPress插件使用PHP-Scoper进行作用域限定并不容易,即使可行,也容易出现bug。

将CMS特定代码与应用程序业务逻辑彻底解耦,可以仅对一部分包(含业务逻辑的包)应用PHP-Scoper,而对其他包(含WordPress代码的包)不应用。我已在此处详细描述了这一策略。

此外,与PHP-Scoper类似,可能还有其他工具在应用于某些CMS特定代码(如WordPress)时会失败。在这些情况下,将包精细化拆分可以解决问题。

可以生成只包含所需代码的不同应用程序

我们可以复用包来生成多个应用程序,每个应用程序只包含它所需的包,不多也不少。

例如,个人博客可能只需要poststagscategories,因此可以避免处理usersuser-login的功能。

事实上,我计划很快利用这一特性:我目前正在开发"Private GraphQL API",这是一个自包含的GraphQL引擎,供WordPress插件开发者打包到其插件中,为Gutenberg区块提供GraphQL API。

我只需从Gato GraphQL插件中删除不需要的包(处理UI、客户端、自定义端点、HTTP缓存、持久化Query等的包),就可以轻松创建"Private GraphQL API"。

最后,由于易于作用域限定(如上所述),我可以为所有必要的包添加前缀,这样Private GraphQL API就能无冲突地运行(当两个不同插件打包不同版本的Private GraphQL API时可能发生冲突)。

这种方式的缺点

不用说,这种方式远非完美。

需要更多工作,代码变得更加冗长

通常,如果应用程序在WordPress上运行,获取文章列表只需执行get_posts即可。简单又方便。

使其CMS无关会使事情变得复杂得多。要获取文章列表,我们必须:

  • 创建posts包和posts-wp
  • posts包中创建包含getPosts函数的契约
  • posts-wp包中通过get_posts满足契约
  • 始终确保通过契约调用功能,而不是直接调用

(很可能)需要依赖注入

我们需要将CMS无关包中的每个契约与CMS特定包中的实现绑定起来。在我的情况下,我使用的是由Symfony的DependencyInjection组件提供的服务容器。

我非常喜欢这种方式,我认为它极大地简化了应用程序。但我理解并非每个应用程序都需要依赖注入,这会增加其复杂性。

(极有可能)需要单体仓库

Gato GraphQL最终包含了91个包。过去,我将每个包托管在各自独立的仓库中,导致创建PR非常困难。因此,我被"迫"切换到单体仓库方式。

说清楚一点:我非常喜欢单体仓库。但我理解不是每个人都喜欢它,而且它本身也需要一定的维护工作。

参考链接

我之前写过关于抽象化WordPress网站使其CMS无关的动机和策略。正是这同一策略,我将其应用于拆分Gato GraphQL的代码库:

附录:构成插件的91个包列表

Gato GraphQL包含以下91个包。

引擎功能:

getpop/access-control
getpop/cache-control
getpop/component-model
getpop/definitions
getpop/engine
getpop/engine-wp
getpop/field-query
getpop/guzzle-helpers
getpop/hooks
getpop/hooks-wp
getpop/loosecontracts
getpop/mandatory-directives-by-configuration
getpop/modulerouting
getpop/query-parsing
getpop/root
getpop/routing
getpop/routing-wp
getpop/translation
getpop/translation-wp
graphql-api/markdown-convertor

API功能:

getpop/api
getpop/api-clients
getpop/api-endpoints
getpop/api-endpoints-for-wp
getpop/api-graphql
getpop/api-mirrorquery

GraphQL服务器功能:

graphql-by-pop/graphql-clients-for-wp
graphql-by-pop/graphql-endpoint-for-wp
graphql-by-pop/graphql-parser
graphql-by-pop/graphql-query
graphql-by-pop/graphql-request
graphql-by-pop/graphql-server

数据模型:

pop-schema/basic-directives
pop-schema/categories
pop-schema/categories-wp
pop-schema/comment-mutations
pop-schema/comment-mutations-wp
pop-schema/commentmeta
pop-schema/commentmeta-wp
pop-schema/comments
pop-schema/comments-wp
pop-schema/custompost-mutations
pop-schema/custompost-mutations-wp
pop-schema/custompostmedia
pop-schema/custompostmedia-mutations
pop-schema/custompostmedia-mutations-wp
pop-schema/custompostmedia-wp
pop-schema/custompostmeta
pop-schema/custompostmeta-wp
pop-schema/customposts
pop-schema/customposts-wp
pop-schema/generic-customposts
pop-schema/media
pop-schema/media-wp
pop-schema/menus
pop-schema/menus-wp
pop-schema/meta
pop-schema/metaquery
pop-schema/metaquery-wp
pop-schema/pages
pop-schema/pages-wp
pop-schema/post-categories
pop-schema/post-categories-wp
pop-schema/post-mutations
pop-schema/post-tags
pop-schema/post-tags-wp
pop-schema/posts
pop-schema/posts-wp
pop-schema/queriedobject
pop-schema/queriedobject-wp
pop-schema/schema-commons
pop-schema/tags
pop-schema/tags-wp
pop-schema/taxonomies
pop-schema/taxonomies-wp
pop-schema/taxonomymeta
pop-schema/taxonomymeta-wp
pop-schema/taxonomyquery
pop-schema/taxonomyquery-wp
pop-schema/user-roles
pop-schema/user-roles-access-control
pop-schema/user-roles-wp
pop-schema/user-state
pop-schema/user-state-access-control
pop-schema/user-state-mutations
pop-schema/user-state-mutations-wp
pop-schema/user-state-wp
pop-schema/usermeta
pop-schema/usermeta-wp
pop-schema/users
pop-schema/users-wp

订阅我们的新闻通讯

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