如何使用PHP类扩展WordPress插件插图

WordPress 插件可以通过附加功能进行扩展,WooCommerce 和 Gravity Forms 等流行插件就证明了这一点。在 “构建支持扩展的 WordPress 插件” 一文中,我们了解到使 WordPress 插件具有可扩展性的两种主要方法:

  1. 为扩展插件设置钩子(动作和过滤器),以便其注入自己的功能
  2. 提供扩展插件可以继承的 PHP 类

第一种方法更多地依赖于文档,详细说明可用的钩子及其用法。相比之下,第二种方法为扩展提供即用代码,减少了对大量文档的需求。这样做的好处是,在创建代码的同时创建文档,会使插件的管理和发布复杂化。

直接提供 PHP 类可以有效地用代码取代文档。插件不是教授如何实现某个功能,而是提供必要的 PHP 代码,从而简化了第三方开发人员的工作。

让我们来探讨一些实现这一目标的技巧,最终目标是围绕我们的 WordPress 插件建立一个集成生态系统。

在 WordPress 插件中定义基础 PHP 类

WordPress 插件将包含供扩展插件使用的 PHP 类。这些 PHP 类可能不会被主插件本身使用,而是专门提供给其他插件使用。

让我们看看开源的 Gato GraphQL 插件是如何实现的。

AbstractPlugin 类:

AbstractPlugin 表示一个插件,包括 Gato GraphQL 主插件及其扩展插件:

abstract class AbstractPlugin implements PluginInterface
{
protected string $pluginBaseName;
protected string $pluginSlug;
protected string $pluginName;
public function __construct(
protected string $pluginFile,
protected string $pluginVersion,
?string $pluginName,
) {
$this->pluginBaseName = plugin_basename($pluginFile);
$this->pluginSlug = dirname($this->pluginBaseName);
$this->pluginName = $pluginName ?? $this->pluginBaseName;
}
public function getPluginName(): string
{
return $this->pluginName;
}
public function getPluginBaseName(): string
{
return $this->pluginBaseName;
}
public function getPluginSlug(): string
{
return $this->pluginSlug;
}
public function getPluginFile(): string
{
return $this->pluginFile;
}
public function getPluginVersion(): string
{
return $this->pluginVersion;
}
public function getPluginDir(): string
{
return dirname($this->pluginFile);
}
public function getPluginURL(): string
{
return plugin_dir_url($this->pluginFile);
}
// ...
}

AbstractMainPlugin 类:

AbstractMainPlugin 扩展了 AbstractPlugin 以表示主插件:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
{
public function __construct(
string $pluginFile,
string $pluginVersion,
?string $pluginName,
protected MainPluginInitializationConfigurationInterface $pluginInitializationConfiguration,
) {
parent::__construct(
$pluginFile,
$pluginVersion,
$pluginName,
);
}
// ...
}

AbstractExtension 类:

同样,AbstractExtension 扩展了 AbstractPlugin 以表示扩展插件:

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
{
public function __construct(
string $pluginFile,
string $pluginVersion,
?string $pluginName,
protected ?ExtensionInitializationConfigurationInterface $extensionInitializationConfiguration,
) {
parent::__construct(
$pluginFile,
$pluginVersion,
$pluginName,
);
}
// ...
}

请注意,AbstractExtension  包含在主插件中,提供了注册和初始化扩展的功能。不过,它只被扩展程序使用,而不是被主插件本身使用。

AbstractPlugin 类包含在不同时间调用的共享初始化代码。这些方法是在祖先级别定义的,但继承类会根据其生命周期进行调用。

主插件和扩展通过执行相应类的 setup 方法进行初始化,该方法在 WordPress 主插件文件中调用。

例如,在 Gato GraphQL 中,这是在 gatographql.php 中完成的:

$pluginFile = __FILE__;
$pluginVersion = '2.4.0';
$pluginName = __('Gato GraphQL', 'gatographql');
PluginApp::getMainPluginManager()->register(new Plugin(
$pluginFile,
$pluginVersion,
$pluginName
))->setup();

setup 方法:

在祖先级别(ancestor level),setup  包含插件及其扩展之间的共同逻辑,例如在插件停用时取消注册它们。该方法不是最终方法;继承类可以重写该方法,以添加自己的功能:

abstract class AbstractPlugin implements PluginInterface
{
// ...
public function setup(): void
{
register_deactivation_hook(
$this->getPluginFile(),
$this->deactivate(...)
);
}
public function deactivate(): void
{
$this->removePluginVersion();
}
private function removePluginVersion(): void
{
$pluginVersions = get_option('gatographql-plugin-versions', []);
unset($pluginVersions[$this->pluginBaseName]);
update_option('gatographql-plugin-versions', $pluginVersions);
}
}

主插件的 setup 方法:

主插件的 setup 方法初始化应用程序的生命周期。它通过initializeconfigureComponentsconfigureboot 等方法执行主插件的功能,并为扩展触发相应的操作钩子:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
{
public function setup(): void
{
parent::setup();
add_action('plugins_loaded', function (): void
{
// 1. Initialize main plugin
$this->initialize();
// 2. Initialize extensions
do_action('gatographql:initializeExtension');
// 3. Configure main plugin components
$this->configureComponents();
// 4. Configure extension components
do_action('gatographql:configureExtensionComponents');
// 5. Configure main plugin
$this->configure();
// 6. Configure extension
do_action('gatographql:configureExtension');
// 7. Boot main plugin
$this->boot();
// 8. Boot extension
do_action('gatographql:bootExtension');
}
// ...
}
// ...
}

扩展 setup 方法:

AbstractExtension 类在相应的钩子上执行其逻辑:

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
{
// ...
final public function setup(): void
{
parent::setup();
add_action('plugins_loaded', function (): void
{
// 2. Initialize extensions
add_action(
'gatographql:initializeExtension',
$this->initialize(...)
);
// 4. Configure extension components
add_action(
'gatographql:configureExtensionComponents',
$this->configureComponents(...)
);
// 6. Configure extension
add_action(
'gatographql:configureExtension',
$this->configure(...)
);
// 8. Boot extension
add_action(
'gatographql:bootExtension',
$this->boot(...)
);
}, 20);
}
}

initializeconfigureComponentsconfigure, 和 boot 方法对主插件和扩展插件都是通用的,并且可以共享逻辑。这些共享逻辑存储在 AbstractPlugin 类中。

例如,configure 方法配置插件或扩展程序,调用 callPluginInitializationConfiguration(主插件和扩展程序有不同的实现方式)和 getModuleClassConfiguration(提供默认行为,但可根据需要重载):

abstract class AbstractPlugin implements PluginInterface
{
// ...
public function configure(): void
{
$this->callPluginInitializationConfiguration();
$appLoader = App::getAppLoader();
$appLoader->addModuleClassConfiguration($this->getModuleClassConfiguration());
}
abstract protected function callPluginInitializationConfiguration(): void;
/**
* @return array,mixed> [key]: Module class, [value]: Configuration
*/
public function getModuleClassConfiguration(): array
{
return [];
}
}

主插件为 callPluginInitializationConfiguration 提供其实现:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
{
// ...
protected function callPluginInitializationConfiguration(): void
{
$this->pluginInitializationConfiguration->initialize();
}
}

同样,扩展类也提供其实现:

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
{
// ...
protected function callPluginInitializationConfiguration(): void
{
$this->extensionInitializationConfiguration?->initialize();
}
}

方法 initializeconfigureComponents 和 boot 定义在祖先级别,继承类可以重载这些方法:

abstract class AbstractPlugin implements PluginInterface
{
// ...
public function initialize(): void
{
$moduleClasses = $this->getModuleClassesToInitialize();
App::getAppLoader()->addModuleClassesToInitialize($moduleClasses);
}
/**
* @return array> List of `Module` class to initialize
*/
abstract protected function getModuleClassesToInitialize(): array;
public function configureComponents(): void
{
$classNamespace = ClassHelpers::getClassPSR4Namespace(get_called_class());
$moduleClass = $classNamespace . '\Module';
App::getModule($moduleClass)->setPluginFolder(dirname($this->pluginFile));
}
public function boot(): void
{
// By default, do nothing
}
}

所有方法都可以被 AbstractMainPlugin 或 AbstractExtension 改写,以扩展它们的自定义功能。

对于主插件,当插件或其任何扩展被激活或停用时,setup 方法也会删除 WordPress 实例中的任何缓存:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
{
public function setup(): void
{
parent::setup();
// ...
// Main-plugin specific methods
add_action(
'activate_plugin',
function (string $pluginFile): void {
$this->maybeRegenerateContainerWhenPluginActivatedOrDeactivated($pluginFile);
}
);
add_action(
'deactivate_plugin',
function (string $pluginFile): void {
$this->maybeRegenerateContainerWhenPluginActivatedOrDeactivated($pluginFile);
}
);
}
public function maybeRegenerateContainerWhenPluginActivatedOrDeactivated(string $pluginFile): void
{
// Removed code for simplicity
}
// ...
}

同样,deactivate 方法会移除缓存,并仅在主插件 boot 时执行额外的操作钩子:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
{
public function deactivate(): void
{
parent::deactivate();
$this->removeTimestamps();
}
protected function removeTimestamps(): void
{
$userSettingsManager = UserSettingsManagerFacade::getInstance();
$userSettingsManager->removeTimestamps();
}
public function boot(): void
{
parent::boot();
add_filter(
'admin_body_class',
function (string $classes): string {
$extensions = PluginApp::getExtensionManager()->getExtensions();
$commercialExtensionActivatedLicenseObjectProperties = SettingsHelpers::getCommercialExtensionActivatedLicenseObjectProperties();
foreach ($extensions as $extension) {
$extensionCommercialExtensionActivatedLicenseObjectProperties = $commercialExtensionActivatedLicenseObjectProperties[$extension->getPluginSlug()] ?? null;
if ($extensionCommercialExtensionActivatedLicenseObjectProperties === null) {
continue;
}
return $classes . ' is-gatographql-customer';
}
return $classes;
}
);
}
}

从上面介绍的所有代码中,我们可以清楚地看到,在设计和编码 WordPress 插件时,我们需要考虑其扩展的需求,并尽可能在它们之间重复使用代码。实施合理的面向对象编程模式(如 SOLID 原则)有助于实现这一目标,使代码库具有长期可维护性。

声明并验证版本依赖关系

由于扩展继承自插件提供的 PHP 类,因此验证插件是否存在所需的版本至关重要。否则可能会导致冲突,导致网站瘫痪。

例如,如果 AbstractExtension 类在更新时发生了重大变化,并从以前的 3.4.0  版本发布了 4.0.0 主版本,那么在未检查版本的情况下加载扩展可能会导致 PHP 错误,从而阻止 WordPress 加载。

为避免出现这种情况,扩展必须验证所安装的插件版本为3.x.x。当安装 4.0.0 版本时,扩展将被禁用,从而避免出现错误。

扩展可以使用以下逻辑完成验证,该逻辑在扩展的主插件文件中的 plugins_loaded 钩子上执行(因为此时主插件已经加载)。该逻辑访问 ExtensionManager 类,该类包含在主插件中,用于管理扩展:

/**
* Create and set-up the extension
*/
add_action(
'plugins_loaded',
function (): void {
/**
* Extension's name and version.
*
* Use a stability suffix as supported by Composer.
*/
$extensionVersion = '1.1.0';
$extensionName = __('Gato GraphQL - Extension Template');
/**
* The minimum version required from the Gato GraphQL plugin
* to activate the extension.
*/
$gatoGraphQLPluginVersionConstraint = '^1.0';
/**
* Validate Gato GraphQL is active
*/
if (!class_exists(GatoGraphQLGatoGraphQLPlugin::class)) {
add_action('admin_notices', function () use ($extensionName) {
printf(
'

%s

', sprintf( __('Plugin %s is not installed or activated. Without it, plugin %s will not be loaded.'), __('Gato GraphQL'), $extensionName ) ); }); return; } $extensionManager = GatoGraphQLGatoGraphQLPluginApp::getExtensionManager(); if (!$extensionManager->assertIsValid( GatoGraphQLExtension::class, $extensionVersion, $extensionName, $gatoGraphQLPluginVersionConstraint )) { return; } // Load Composer’s autoloader require_once(__DIR__ . '/vendor/autoload.php'); // Create and set-up the extension instance $extensionManager->register(new GatoGraphQLExtension( __FILE__, $extensionVersion, $extensionName, ))->setup(); } );

请注意扩展是如何声明依赖于主插件的版本约束 ^1.0 (使用Composer 的版本约束)。因此,当安装 Gato GraphQL 2.0.0版本时,扩展将不会被激活。

版本约束通过 ExtensionManager::assertIsValid 方法进行验证,该方法调用Semver::satisfies(由 composer/semver package 提供):

use ComposerSemverSemver;
class ExtensionManager extends AbstractPluginManager
{
/**
* Validate that the required version of the Gato GraphQL for WP plugin is installed.
*
* If the assertion fails, it prints an error on the WP admin and returns false
*
* @param string|null $mainPluginVersionConstraint the semver version constraint required for the plugin (eg: "^1.0" means >=1.0.0 and getPlugin();
$mainPluginVersion = $mainPlugin->getPluginVersion();
if (
$mainPluginVersionConstraint !== null && !Semver::satisfies(
$mainPluginVersion,
$mainPluginVersionConstraint
)
) {
$this->printAdminNoticeErrorMessage(
sprintf(
__('Extension or bundle %s requires plugin %s to satisfy version constraint %s, but the current version %s does not. The extension or bundle has not been loaded.', 'gatographql'),
$extensionName ?? $extensionClass,
$mainPlugin->getPluginName(),
$mainPluginVersionConstraint,
$mainPlugin->getPluginVersion(),
)
);
return false;
}
return true;
}
protected function printAdminNoticeErrorMessage(string $errorMessage): void
{
add_action('admin_notices', function () use ($errorMessage): void {
$adminNotice_safe = sprintf(
'

%s

', $errorMessage ); echo $adminNotice_safe; }); } }

针对 WordPress 服务器运行集成测试

为了让第三方开发人员更容易为您的插件创建扩展,请为他们提供开发和测试工具,包括持续集成和持续交付(CI/CD)流程的工作流。

在开发过程中,任何人都可以使用 DevKinsta 轻松启动网络服务器,安装他们为之编码扩展的插件,并立即验证扩展是否与插件兼容。

要在 CI/CD 期间自动进行测试,我们需要通过网络将网络服务器接入 CI/CD 服务。InstaWP 等服务可以为此创建一个安装了 WordPress 的沙盒网站。

如果扩展的代码库托管在 GitHub 上,开发人员可以使用 GitHub Actions 针对 InstaWP 服务运行集成测试。以下工作流程在 InstaWP 沙盒网站上安装扩展(与主插件的最新稳定版本一起),然后运行集成测试:

name: Integration tests (InstaWP)
on:
workflow_run:
workflows: [Generate plugins]
types:
- completed
jobs:
provide_data:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
name: Retrieve the GitHub Action artifact URLs to install in InstaWP
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.1
coverage: none
env:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: "ramsey/composer-install@v2"
- name: Retrieve artifact URLs from GitHub workflow
uses: actions/github-script@v6
id: artifact-url
with:
script: |
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const artifactURLs = allArtifacts.data.artifacts.map((artifact) => {
return artifact.url.replace('https://api.github.com/repos', 'https://nightly.link') + '.zip'
}).concat([
"https://downloads.wordpress.org/plugin/gatographql.latest-stable.zip"
]);
return artifactURLs.join(',');
result-encoding: string
- name: Artifact URL for InstaWP
run: echo "Artifact URL for InstaWP - ${{ steps.artifact-url.outputs.result }}"
shell: bash
outputs:
artifact_url: ${{ steps.artifact-url.outputs.result }}
process:
needs: provide_data
name: Launch InstaWP site from template 'integration-tests' and execute integration tests against it
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.1
coverage: none
env:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: "ramsey/composer-install@v2"
- name: Create InstaWP instance
uses: instawp/wordpress-testing-automation@main
id: create-instawp
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INSTAWP_TOKEN: ${{ secrets.INSTAWP_TOKEN }}
INSTAWP_TEMPLATE_SLUG: "integration-tests"
REPO_ID: 25
INSTAWP_ACTION: create-site-template
ARTIFACT_URL: ${{ needs.provide_data.outputs.artifact_url }}
- name: InstaWP instance URL
run: echo "InstaWP instance URL - ${{ steps.create-instawp.outputs.instawp_url }}"
shell: bash
- name: Extract InstaWP domain
id: extract-instawp-domain        
run: |
instawp_domain="$(echo "${{ steps.create-instawp.outputs.instawp_url }}" | sed -e s#https://##)"
echo "instawp-domain=$(echo $instawp_domain)" >> $GITHUB_OUTPUT
- name: Run tests
run: |
INTEGRATION_TESTS_WEBSERVER_DOMAIN=${{ steps.extract-instawp-domain.outputs.instawp-domain }} 
INTEGRATION_TESTS_AUTHENTICATED_ADMIN_USER_USERNAME=${{ steps.create-instawp.outputs.iwp_wp_username }} 
INTEGRATION_TESTS_AUTHENTICATED_ADMIN_USER_PASSWORD=${{ steps.create-instawp.outputs.iwp_wp_password }} 
vendor/bin/phpunit --filter=Integration
- name: Destroy InstaWP instance
uses: instawp/wordpress-testing-automation@main
id: destroy-instawp
if: ${{ always() }}
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INSTAWP_TOKEN: ${{ secrets.INSTAWP_TOKEN }}
INSTAWP_TEMPLATE_SLUG: "integration-tests"
REPO_ID: 25
INSTAWP_ACTION: destroy-site

该工作流程通过 Nightly Link 访问 .zip 文件。服务允许在不登录的情况下访问 GitHub 上的工件,从而简化了 InstaWP 的配置。

发布扩展插件

我们可以提供工具帮助发布扩展插件,尽可能实现程序自动化。

Monorepo Builder 是一个用于管理任何 PHP 项目(包括 WordPress 插件)的库。它提供了 monorepo-builder release 命令,用于发布项目版本,并根据语义版本法递增版本的 major、minor 或 patch 组件。

该命令会执行一系列发布 Worker,即执行特定逻辑的 PHP 类。默认的 Worker 包括一个创建新版本 git tag 的 Worker 和一个将标签推送到远程仓库的 Worker。自定义 Worker 可以在这些步骤之前、之后或之间注入。

发布 Worker 通过配置文件进行配置:

use SymplifyMonorepoBuilderConfigMBConfig;
use SymplifyMonorepoBuilderReleaseReleaseWorkerAddTagToChangelogReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerPushNextDevReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerPushTagReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerSetCurrentMutualDependenciesReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerSetNextMutualDependenciesReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerTagVersionReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerUpdateBranchAliasReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerUpdateReplaceReleaseWorker;
return static function (MBConfig $mbConfig): void {
// release workers - in order to execute
$mbConfig->workers([
UpdateReplaceReleaseWorker::class,
SetCurrentMutualDependenciesReleaseWorker::class,
AddTagToChangelogReleaseWorker::class,
TagVersionReleaseWorker::class,
PushTagReleaseWorker::class,
SetNextMutualDependenciesReleaseWorker::class,
UpdateBranchAliasReleaseWorker::class,
PushNextDevReleaseWorker::class,
]);
};

我们可以根据 WordPress 插件的需要提供定制的发布程序,以增强发布流程。例如,InjectStableTagVersionInPluginReadmeFileReleaseWorker会将新版本设置为扩展程序 readme.txt 文件中的 “Stable tag” 条目:

use NetteUtilsStrings;
use PharIoVersionVersion;
use SymplifySmartFileSystemSmartFileInfo;
use SymplifySmartFileSystemSmartFileSystem;
class InjectStableTagVersionInPluginReadmeFileReleaseWorker implements ReleaseWorkerInterface
{
public function __construct(
// This class is provided by the Monorepo Builder
private SmartFileSystem $smartFileSystem,
) {
}
public function getDescription(Version $version): string
{
return 'Have the "Stable tag" point to the new version in the plugin's readme.txt file';
}
public function work(Version $version): void
{
$replacements = [
'/Stable tag:s+[a-z0-9.-]+/' => 'Stable tag: ' . $version->getVersionString(),
];
$this->replaceContentInFiles(['/readme.txt'], $replacements);
}
/**
* @param string[] $files
* @param array $regexPatternReplacements regex pattern to search, and its replacement
*/
protected function replaceContentInFiles(array $files, array $regexPatternReplacements): void
{
foreach ($files as $file) {
$fileContent = $this->smartFileSystem->readFile($file);
foreach ($regexPatternReplacements as $regexPattern => $replacement) {
$fileContent = Strings::replace($fileContent, $regexPattern, $replacement);
}
$this->smartFileSystem->dumpFile($file, $fileContent);
}
}
}

通过在配置列表中添加 InjectStableTagVersionInPluginReadmeFileReleaseWorker,每当执行 monorepo-builder release 命令发布新版本插件时,扩展的 readme.txt 文件中的 “Stable tag” 就会自动更新。

向 WP.org 目录发布扩展插件

我们还可以发布一个工作流程,帮助将扩展发布到 WordPress 插件目录。在远程版本库中标记项目时,以下工作流程将把 WordPress 扩展插件发布到目录中:

# See: https://github.com/10up/action-wordpress-plugin-deploy#deploy-on-pushing-a-new-tag
name: Deploy to WordPress.org Plugin Directory (SVN)
on:
push:
tags:
- "*"
jobs:
tag:
name: New tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: WordPress Plugin Deploy
uses: 10up/action-wordpress-plugin-deploy@stable
env:
SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
SLUG: ${{ secrets.SLUG }}

此工作流程使用 10up/action-wordpress-plugin-deploy 操作,该操作会从 Git 代码库中获取代码并将其推送到 WordPress.org SVN 代码库,从而简化了操作。

小结

在为 WordPress 创建可扩展插件时,我们的目标是尽可能方便第三方开发人员对其进行扩展,从而最大限度地围绕我们的插件培育出一个充满活力的生态系统。

虽然提供大量文档可以指导开发人员如何扩展插件,但更有效的方法是为开发、测试和发布扩展提供必要的 PHP 代码和工具。

通过在插件中直接包含扩展所需的附加代码,我们简化了开发人员的开发过程。

您打算让自己的 WordPress 插件具有可扩展性吗?请在评论区告诉我们。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。