WordPress抽象代码最佳实践和相关插件

WordPress抽象代码最佳实践和相关插件

WordPress 是一个历史悠久的内容管理系统,但也是使用最多的一种。由于支持过时的 PHP 版本和遗留代码,WordPress 在实施现代编码实践方面仍然存在不足,WordPress 抽象就是一个例子。

例如,如果能将 WordPress 核心代码库拆分成由 Composer 管理的软件包,效果会好得多。又或者,从文件路径自动加载 WordPress 类。

本文将详细介绍代码抽象相关的信息,WordPress 代码抽象的最佳实践及相关插件。

WordPress 与 PHP 工具的集成问题

由于其古老的架构,我们在将 WordPress 与 PHP 代码库工具(如静态分析器 PHPStan、单元测试库 PHPUnit 和命名空间范围库 PHP-Scoper)集成时偶尔会遇到问题。例如,请考虑以下情况:

  • 在 WordPress 5.6 支持 PHP 8.0 之前,Yoast 的一份报告描述了在 WordPress 核心上运行 PHPStan 会产生数千个问题
  • 由于WordPress仍支持PHP 5.6,因此 WordPress 测试套件目前只支持 PHPUnit 到 7.5 版本,而 7.5 版本的生命周期已经结束。
  • 通过 PHP-Scoper 扩展 WordPress 插件非常具有挑战性

在我们的项目中,WordPress 代码只占总代码的一小部分;项目还将包含与底层 CMS 无关的业务代码。然而,仅仅因为有一些 WordPress 代码,项目就可能无法与工具正常集成。

有鉴于此,将项目拆分成若干个软件包,其中一些包含 WordPress 代码,另一些则只包含使用 “vanilla” PHP 的业务代码,而不包含 WordPress 代码。这样,后面这些软件包就不会受到上述问题的影响,而是可以与工具完美集成。

什么是代码抽象?

代码抽象可以消除代码中的固定依赖关系,生成通过契约相互影响的软件包。这些软件包可以通过不同的堆栈添加到不同的应用程序中,从而最大限度地提高其可用性。代码抽象的结果是基于以下支柱的简洁的解耦代码库:

  1. 针对接口而非实现进行编码。
  2. 创建包并通过 Composer 发布。
  3. 通过依赖注入将所有部分粘合在一起。

针对接口而非实现进行编码

针对接口编码是指使用合约让代码片段相互交互的做法。合约是一个简单的 PHP 接口(或任何其他语言),它定义了哪些函数可用及其签名,即它们接收哪些输入及其输出。

接口声明了功能的意图,但不解释功能将如何实现。通过接口访问功能,我们的应用程序可以依赖自主的代码片段来实现特定目标,而无需知道或关心它们是如何实现的。这样,应用程序就不需要进行调整,就能切换到另一段代码来实现相同的目标,例如,从不同的提供商那里获取代码。

合约示例

下面的代码使用了 Symfony 的合约  CacheInterface 和 PHP 标准建议(PSR)合约  CacheItemInterface  来实现缓存功能:

use Psr\Cache\CacheItemInterface;
use Symfony\Contracts\Cache\CacheInterface;
$value = $cache->get('my_cache_key', function (CacheItemInterface $item) {
$item->expiresAfter(3600);
return 'foobar';
});

$cache 实现了 CacheInterface,它定义了从缓存中获取对象的 get 方法。通过合约访问该功能,应用程序可以忽略缓存的位置。无论它是在内存、磁盘、数据库、网络还是其他任何地方。但它仍然必须执行该功能。CacheItemInterface 定义了 expiresAfter 方法,用于声明项目必须在缓存中保留多长时间。应用程序可以调用该方法,而不必关心缓存的对象是什么;它只关心该对象必须被缓存多长时间。

针对 WordPress 中的接口编码

由于我们对 WordPress 代码进行了抽象,因此应用程序不会直接引用 WordPress 代码,而总是通过接口来引用。例如,WordPress的函数 get_posts 就有这样的签名:

/**
* @param array $args
* @return WP_Post[]|int[] Array of post objects or post IDs.
*/
function get_posts( $args = null )

我们可以通过 Owner\MyApp\Contracts\PostsAPIInterface 合同来访问它,而不是直接调用这个方法:

namespace Owner\MyApp\Contracts;
interface PostAPIInterface
{
public function get_posts(array $args = null): PostInterface[]|int[];
}

请注意,WordPress 函数 get_posts 可以返回类 WP_Post 的对象,这是 WordPress 特有的。在抽象代码时,我们需要删除这种固定的依赖关系。合约中的 get_posts 方法会返回 PostInterface 类型的对象,这样就可以不明确地引用 WP_Post 类。PostInterface 类需要提供对 WP_Post 所有方法和属性的访问权限:

namespace Owner\MyApp\Contracts;
interface PostInterface
{
public function get_ID(): int;
public function get_post_author(): string;
public function get_post_date(): string;
// ...
}

执行这一策略可以改变我们对 WordPress 在堆栈中位置的理解。我们不再把 WordPress 视为应用程序本身(我们在其上安装主题和插件),而是将其视为应用程序中的另一个依赖项,与其他组件一样可以替换。(尽管我们在实践中不会替换 WordPress,但从概念上讲,它是可以替换的)。

创建和分发软件包

Composer 是 PHP 的软件包管理器。它允许 PHP 应用程序从资源库中获取软件包(即代码片段),并将其作为依赖项安装。为了将应用程序与 WordPress 解耦,我们必须将其代码分成两种不同类型的包:包含 WordPress 代码的包和包含业务逻辑(即不包含 WordPress 代码)的包。

最后,我们将所有软件包作为依赖项添加到应用程序中,并通过 Composer 进行安装。由于工具将应用于业务代码包,因此这些包必须包含应用程序的大部分代码;百分比越高越好。让它们管理整个代码的 90% 左右是个不错的目标。

将 WordPress 代码提取到代码包中

按照前面的例子,PostAPIInterfacePostInterface 合同将被添加到包含业务代码的包中,而另一个包将包含这些合同的 WordPress 实现。为了满足 PostInterface 的要求,我们创建了一个 PostWrapper 类,它将从 WP_Post 对象中获取所有属性:

namespace Owner\MyAppForWP\ContractImplementations;
use Owner\MyApp\Contracts\PostInterface;
use WP_Post;
class PostWrapper implements PostInterface
{
private WP_Post $post;
public function __construct(WP_Post $post)
{
$this->post = $post;
}
public function get_ID(): int
{
return $this->post->ID;
}
public function get_post_author(): string
{
return $this->post->post_author;
}
public function get_post_date(): string
{
return $this->post->post_date;
}
// ...
}

在实现 PostAPI 时,由于 get_posts 方法返回 PostInterface[],我们必须将 WP_Post 对象转换为 PostWrapper 对象:

namespace Owner\MyAppForWP\ContractImplementations;
use Owner\MyApp\Contracts\PostAPIInterface;
use WP_Post;
class PostAPI implements PostAPIInterface
{
public function get_posts(array $args = null): PostInterface[]|int[]
{
// This var will contain WP_Post[] or int[]
$wpPosts = \get_posts($args);
// Convert WP_Post[] to PostWrapper[]
return array_map(
function (WP_Post|int $post) {
if ($post instanceof WP_Post) {
return new PostWrapper($post);
}
return $post
},
$wpPosts
);
}
}

使用依赖注入

依赖注入是一种设计模式,可让您以松散耦合的方式将所有应用程序部分粘合在一起。通过依赖注入,应用程序通过合约访问服务,而合约实现则通过配置 “注入 “到应用程序中。

只需更改配置,我们就能轻松地从一个合约提供者切换到另一个合约提供者。我们可以选择多种依赖注入库。我们建议选择一个符合 PHP 标准建议(通常称为 “PSR”)的库,这样我们就可以在需要时轻松地用另一个库代替。关于依赖注入,库必须符合 PSR-11,它提供了 “容器接口” 的规范。以下库符合 PSR-11 标准:

通过服务容器访问服务

依赖注入库将提供一个 “服务容器”,它将合同解析为相应的实现类。应用程序必须依赖服务容器来访问所有功能。例如,我们通常会直接调用 WordPress 函数:

$posts = get_posts();

……有了服务容器,我们必须首先获取满足 PostAPIInterface 的服务,并通过它执行功能:

use Owner\MyApp\Contracts\PostAPIInterface;
// Obtain the service container, as specified by the library we use
$serviceContainer = ContainerBuilderFactory::getInstance();
// The obtained service will be of class Owner\MyAppForWP\ContractImplementations\PostAPI
$postAPI = $serviceContainer->get(PostAPIInterface::class);
// Now we can invoke the WordPress functionality
$posts = $postAPI->get_posts();

使用 Symfony 的依赖注入

Symfony 的 DependencyInjection 组件是目前最流行的依赖注入库。它允许你通过 PHP、YAML 或 XML 代码来配置服务容器。例如,要通过类 PostAPI 来定义 PostAPIInterface 合同,可以这样在 YAML 中配置:

services:
Owner\MyApp\Contracts\PostAPIInterface:
class: \Owner\MyAppForWP\ContractImplementations\PostAPI

Symfony 的依赖注入(DependencyInjection)还允许将一个服务的实例自动注入(或 “autowired”)到依赖于它的任何其他服务中。此外,它还可以轻松定义一个类是其自身服务的实现。例如,请看下面的 YAML 配置

services:
_defaults:
public: true
autowire: true
GraphQLAPI\GraphQLAPI\Registries\UserAuthorizationSchemeRegistryInterface:
class: '\GraphQLAPI\GraphQLAPI\Registries\UserAuthorizationSchemeRegistry'
GraphQLAPI\GraphQLAPI\Security\UserAuthorizationInterface:
class: '\GraphQLAPI\GraphQLAPI\Security\UserAuthorization'
GraphQLAPI\GraphQLAPI\Security\UserAuthorizationSchemes\:
resource: '../src/Security/UserAuthorizationSchemes/*'

该配置定义了以下内容:

  • 通过类 UserAuthorizationSchemeRegistry 满足合约 UserAuthorizationSchemeRegistryInterface
  • 通过类 UserAuthorization 满足合约 UserAuthorizationInterface
  • UserAuthorizationSchemes/ 文件夹下的所有类都是其自身的实现
  • 服务之间必须自动注入( autowire: true

让我们看看自动连接是如何工作的。UserAuthorization 类依赖于带有 UserAuthorizationSchemeRegistryInterface 合约的服务:

class UserAuthorization implements UserAuthorizationInterface
{
public function __construct(
protected UserAuthorizationSchemeRegistryInterface $userAuthorizationSchemeRegistry
) {
}
// ...
}

由于使用了 autowire: true,DependencyInjection 组件将自动让 UserAuthorization 服务接收其所需的依赖关系,即 UserAuthorizationSchemeRegistry 的实例。

何时抽象

抽象代码会耗费大量的时间和精力,因此我们只有在利大于弊的情况下才进行抽象。以下是值得抽象代码的建议。您可以使用本文中的代码片段或下面建议的抽象 WordPress 插件来实现这一点。

获取工具

如前所述,在 WordPress 上运行 PHP-Scoper 是很困难的。通过将 WordPress 代码解耦为不同的软件包,就可以直接使用 WordPress 插件

减少工具开发时间和成本

运行 PHPUnit 测试套件时,需要初始化和运行 WordPress 的时间比不需要初始化和运行 WordPress 的时间长。时间越少,运行测试所需的费用也就越少 – 例如,GitHub Actions 会根据使用时间对 GitHub 托管的运行程序收费。

不需要大量重构

现有项目可能需要进行大量重构才能引入所需的架构(依赖注入、将代码拆分成包等),因此很难抽离。在从头开始创建项目时对代码进行抽象,则更易于管理。

为多个平台生成代码

通过将 90% 的代码提取到与 CMS 无关的软件包中,我们只需替换整个代码库中的 10%,就能生成适用于不同 CMS 或框架的库版本。

迁移到不同的平台

如果我们需要将一个项目从 Drupal 迁移到 WordPress、从 WordPress 迁移到 Laravel 或其他任何组合,那么只需重写 10%的代码,这将大大节省成本。

最佳实践

在设计合同以抽象我们的代码时,我们可以对代码库进行一些改进。

遵守 PSR-12

在定义访问 WordPress 方法的接口时,我们应遵守 PSR-12。这一最新规范旨在减少扫描不同作者代码时的认知摩擦。遵守 PSR-12 意味着重新命名 WordPress 函数。

WordPress 使用 snake_case 写为函数命名,而 PSR-12 则使用 camelCase。因此,函数 get_posts 将变为 getPosts

interface PostAPIInterface
{
public function getPosts(array $args = null): PostInterface[]|int[];
}

……还有:

class PostAPI implements PostAPIInterface
{
public function getPosts(array $args = null): PostInterface[]|int[]
{
// This var will contain WP_Post[] or int[]
$wpPosts = \get_posts($args);
// Rest of the code
// ...
}
}

拆分方法

接口中的方法不必完全照搬 WordPress 中的方法。只要合理,我们就可以对它们进行转换。例如,WordPress 函数 get_user_by($field,$value) 知道如何通过参数 $field 从数据库中检索用户,该参数可接受的值有  "id""ID""slug""email" 或 "login"。这种设计存在一些问题:

  • 如果我们传递了一个错误的字符串,它不会在编译时失败
  • 参数 $value 需要接受所有选项的所有不同类型,即使在传递 "ID" 时,它期望的是一个 int,但在传递 "email" 时,它只能接收一个 string

我们可以通过将函数拆分成几个函数来改善这种情况:

namespace Owner\MyApp\Contracts;
interface UserAPIInterface
{
public function getUserById(int $id): ?UserInterface;
public function getUserByEmail(string $email): ?UserInterface;
public function getUserBySlug(string $slug): ?UserInterface;
public function getUserByLogin(string $login): ?UserInterface;
}

对于 WordPress 来说,合同是这样解决的(假设我们已经创建了 UserWrapperUserInterface,如前所述):

namespace Owner\MyAppForWP\ContractImplementations;
use Owner\MyApp\Contracts\UserAPIInterface;
class UserAPI implements UserAPIInterface
{
public function getUserById(int $id): ?UserInterface
{
return $this->getUserByProp('id', $id);
}
public function getUserByEmail(string $email): ?UserInterface
{
return $this->getUserByProp('email', $email);
}
public function getUserBySlug(string $slug): ?UserInterface
{
return $this->getUserByProp('slug', $slug);
}
public function getUserByLogin(string $login): ?UserInterface
{
return $this->getUserByProp('login', $login);
}
private function getUserByProp(string $prop, int|string $value): ?UserInterface
{
if ($user = \get_user_by($prop, $value)) {
return new UserWrapper($user);
}
return null;
}
}

删除函数签名中的实现细节

WordPress 中的函数可能会在自己的签名中提供如何实现的信息。在从抽象的角度评估函数时,可以删除这些信息。例如,WordPress 中获取用户姓氏的方法是调用 get_the_author_meta,明确说明用户姓氏是作为 “meta” 值存储的(在 wp_usermeta 表中):

$userLastname = get_the_author_meta("user_lastname", $user_id);

您不必将这些信息传达给合同。接口只关心 “是什么”,而不关心 “怎么做”。因此,合约中可以有一个 getUserLastname 方法,但不提供任何关于如何实现的信息:

interface UserAPIInterface
{
public function getUserLastname(UserWrapper $userWrapper): string;
...
}

添加更严格的类型

WordPress 的某些函数可以以不同的方式接收参数,从而导致歧义。例如,函数 add_query_arg 可以接收单个键和值:

$url = add_query_arg('id', 5, $url);

… 或 key => value 的数组:

$url = add_query_arg(['id' => 5], $url);

我们的界面可以将这些函数拆分成几个独立的函数,每个函数接受一个独特的输入组合,从而定义一个更易于理解的意图:

public function addQueryArg(string $key, string $value, string $url);
public function addQueryArgs(array $keyValues, string $url);

消除技术债务

WordPress 函数 get_posts 不仅返回 “帖子”,也返回 “页面 “或任何 “自定义帖子” 类型的实体,而这些实体是不能互换的。帖子和页面都是自定义帖子,但页面既不是帖子,也不是页面。因此,执行 get_posts 可以返回页面。这种行为是概念上的差异。

为使其正确,get_posts 应被称为 get_customposts,但 WordPress 核心从未对其进行重命名。这是大多数长期软件的常见问题,被称为 “技术债务”-代码存在问题,但由于引入了破坏性更改而从未得到修复。

不过,在创建合同时,我们有机会避免这种技术债务。在这种情况下,我们可以创建一个新的接口 ModelAPIInterface,它可以处理不同类型的实体,然后我们再创建几个方法,每个方法处理不同类型的实体:

interface ModelAPIInterface
{
public function getPosts(array $args): array;
public function getPages(array $args): array;
public function getCustomPosts(array $args): array;
}

这样,就不会再出现差异,您将看到这些结果:

  • getPosts 只返回帖子
  • getPages 只返回页面
  • getCustomPosts 返回帖子和页面

抽象代码的好处

抽象应用程序代码的主要优点有:

  • 在仅包含业务代码的软件包上运行的工具更易于设置,运行所需的时间(和资金)也更少。
  • 我们可以使用与 WordPress 不兼容的工具,例如使用 PHP-Scoper 对插件进行扫描。
  • 我们制作的软件包可以很容易地自主用于其他应用程序。
  • 将应用程序迁移到其他平台变得更加容易。
  • 我们可以将思维方式从 WordPress 转变为业务逻辑思维。
  • 合约描述了应用程序的意图,使其更易于理解。
  • 应用程序通过软件包进行组织,创建一个包含最基本内容的精简应用程序,并根据需要逐步增强。
  • 我们可以清除技术债务。

抽象代码的问题

抽象应用程序代码的缺点是:

  • 一开始需要做大量工作。
  • 代码变得更加冗长;为了实现同样的结果,需要增加额外的代码层。
  • 最终可能会产生几十个软件包,而这些软件包又必须进行管理和维护。
  • 您可能需要一个 monorepo 来统一管理所有软件包。
  • 依赖注入对于简单的应用程序来说可能是矫枉过正(收益递减)。
  • 抽象代码永远无法完全实现,因为 CMS 的架构中通常隐含着一种普遍的偏好。

抽象 WordPress 插件选项

虽然通常最明智的做法是先将代码提取到本地环境,然后再对其进行处理,但一些 WordPress 插件可以帮助你实现抽象目标。这些是我们的首选。

1. WPide

由 WebFactory Ltd 制作的 WPide 插件很受欢迎,它极大地扩展了 WordPress 默认代码编辑器的功能。作为一款抽象的 WordPress 插件,它允许您在原处查看代码,以便更直观地了解需要注意的地方。

 

WPide 插件

WPide 插件

WPide 还具有搜索和替换功能,可快速查找过时或过期的代码,并用重构后的代码进行替换。

除此之外,WPide 还提供了大量额外功能,包括:

  • 语法和代码块高亮
  • 自动备份
  • 创建文件和文件夹
  • 全面的文件树浏览器
  • 访问 WordPress 文件系统 API

2. Ultimate DB Manager

来自 WPHobby 的 Ultimate WP DB Manager 插件为您提供了一种快速方法,可完整下载您的数据库,以便提取和重构。

Ultimate DB Manager 插件

Ultimate DB Manager 插件

3. 自己定制的抽象 WordPress 插件

最后,抽象的最佳选择永远是创建自己的插件。这看似是一项大工程,但如果你直接管理 WordPress 核心文件的能力有限,这就提供了一种便于抽象的变通办法。

这样做的好处显而易见:

  • 从主题文件中抽取功能
  • 在主题更改和数据库更新时保留代码

您可以通过 WordPress 的《插件开发者手册》了解如何创建抽象 WordPress 插件。

小结

我们是否应该对应用程序中的代码进行抽象?就像任何事情一样,没有预定义的 “正确答案”,因为这取决于每个项目的具体情况。那些需要花费大量时间使用 PHPUnit 或 PHPStan 进行分析的项目可以从中获益最多,但付出的努力并不总是值得的。

您已经了解了开始抽象 WordPress 代码所需的一切知识。

你打算在你的项目中实施这一策略吗?如果是,您会使用抽象 WordPress 插件吗?请在评论区告诉我们!

评论留言