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 代码。这样,后面这些软件包就不会受到上述问题的影响,而是可以与工具完美集成。
什么是代码抽象?
代码抽象可以消除代码中的固定依赖关系,生成通过契约相互影响的软件包。这些软件包可以通过不同的堆栈添加到不同的应用程序中,从而最大限度地提高其可用性。代码抽象的结果是基于以下支柱的简洁的解耦代码库:
针对接口而非实现进行编码
针对接口编码是指使用合约让代码片段相互交互的做法。合约是一个简单的 PHP 接口(或任何其他语言),它定义了哪些函数可用及其签名,即它们接收哪些输入及其输出。
接口声明了功能的意图,但不解释功能将如何实现。通过接口访问功能,我们的应用程序可以依赖自主的代码片段来实现特定目标,而无需知道或关心它们是如何实现的。这样,应用程序就不需要进行调整,就能切换到另一段代码来实现相同的目标,例如,从不同的提供商那里获取代码。
合约示例
下面的代码使用了 Symfony 的合约 CacheInterface
和 PHP 标准建议(PSR)合约 CacheItemInterface
来实现缓存功能:
use PsrCacheCacheItemInterface; use SymfonyContractsCacheCacheInterface; $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 )
我们可以通过 OwnerMyAppContractsPostsAPIInterface
合同来访问它,而不是直接调用这个方法:
namespace OwnerMyAppContracts; 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 OwnerMyAppContracts; 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 代码提取到代码包中
按照前面的例子,PostAPIInterface
和 PostInterface
合同将被添加到包含业务代码的包中,而另一个包将包含这些合同的 WordPress 实现。为了满足 PostInterface
的要求,我们创建了一个 PostWrapper
类,它将从 WP_Post
对象中获取所有属性:
namespace OwnerMyAppForWPContractImplementations; use OwnerMyAppContractsPostInterface; 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 OwnerMyAppForWPContractImplementations; use OwnerMyAppContractsPostAPIInterface; 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 标准:
- Symfony’s DependencyInjection
- PHP-DI
- Aura.Di
- Container (Dependency Injection)
- Yii Dependency Injection
通过服务容器访问服务
依赖注入库将提供一个 “服务容器”,它将合同解析为相应的实现类。应用程序必须依赖服务容器来访问所有功能。例如,我们通常会直接调用 WordPress 函数:
$posts = get_posts();
……有了服务容器,我们必须首先获取满足 PostAPIInterface
的服务,并通过它执行功能:
use OwnerMyAppContractsPostAPIInterface; // Obtain the service container, as specified by the library we use $serviceContainer = ContainerBuilderFactory::getInstance(); // The obtained service will be of class OwnerMyAppForWPContractImplementationsPostAPI $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: OwnerMyAppContractsPostAPIInterface: class: OwnerMyAppForWPContractImplementationsPostAPI
Symfony 的依赖注入(DependencyInjection)还允许将一个服务的实例自动注入(或 “autowired”)到依赖于它的任何其他服务中。此外,它还可以轻松定义一个类是其自身服务的实现。例如,请看下面的 YAML 配置:
services: _defaults: public: true autowire: true GraphQLAPIGraphQLAPIRegistriesUserAuthorizationSchemeRegistryInterface: class: 'GraphQLAPIGraphQLAPIRegistriesUserAuthorizationSchemeRegistry' GraphQLAPIGraphQLAPISecurityUserAuthorizationInterface: class: 'GraphQLAPIGraphQLAPISecurityUserAuthorization' GraphQLAPIGraphQLAPISecurityUserAuthorizationSchemes: 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 OwnerMyAppContracts; 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 来说,合同是这样解决的(假设我们已经创建了 UserWrapper
和 UserInterface
,如前所述):
namespace OwnerMyAppForWPContractImplementations; use OwnerMyAppContractsUserAPIInterface; 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 还提供了大量额外功能,包括:
- 语法和代码块高亮
- 自动备份
- 创建文件和文件夹
- 全面的文件树浏览器
- 访问 WordPress 文件系统 API
2. Ultimate DB Manager
来自 WPHobby 的 Ultimate WP DB Manager 插件为您提供了一种快速方法,可完整下载您的数据库,以便提取和重构。
Ultimate DB Manager 插件
3. 自己定制的抽象 WordPress 插件
最后,抽象的最佳选择永远是创建自己的插件。这看似是一项大工程,但如果你直接管理 WordPress 核心文件的能力有限,这就提供了一种便于抽象的变通办法。
这样做的好处显而易见:
- 从主题文件中抽取功能
- 在主题更改和数据库更新时保留代码
您可以通过 WordPress 的《插件开发者手册》了解如何创建抽象 WordPress 插件。
小结
我们是否应该对应用程序中的代码进行抽象?就像任何事情一样,没有预定义的 “正确答案”,因为这取决于每个项目的具体情况。那些需要花费大量时间使用 PHPUnit 或 PHPStan 进行分析的项目可以从中获益最多,但付出的努力并不总是值得的。
您已经了解了开始抽象 WordPress 代码所需的一切知识。
你打算在你的项目中实施这一策略吗?如果是,您会使用抽象 WordPress 插件吗?请在评论区告诉我们!