WordPress 插件可以通过附加功能进行扩展,WooCommerce 和 Gravity Forms 等流行插件就证明了这一点。在 “构建支持扩展的 WordPress 插件” 一文中,我们了解到使 WordPress 插件具有可扩展性的两种主要方法:
- 为扩展插件设置钩子(动作和过滤器),以便其注入自己的功能
- 提供扩展插件可以继承的 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
方法初始化应用程序的生命周期。它通过initialize
、configureComponents
、configure
和boot
等方法执行主插件的功能,并为扩展触发相应的操作钩子:
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); } }
initialize
, configureComponents
, configure
, 和 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<class-string<ModuleInterface>,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(); } }
方法 initialize
、configureComponents
和 boot
定义在祖先级别,继承类可以重载这些方法:
abstract class AbstractPlugin implements PluginInterface { // ... public function initialize(): void { $moduleClasses = $this->getModuleClassesToInitialize(); App::getAppLoader()->addModuleClassesToInitialize($moduleClasses); } /** * @return array<class-string<ModuleInterface>> 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(\GatoGraphQL\GatoGraphQL\Plugin::class)) { add_action('admin_notices', function () use ($extensionName) { printf( '<div class="notice notice-error"><p>%s</p></div>', sprintf( __('Plugin <strong>%s</strong> is not installed or activated. Without it, plugin <strong>%s</strong> will not be loaded.'), __('Gato GraphQL'), $extensionName ) ); }); return; } $extensionManager = \GatoGraphQL\GatoGraphQL\PluginApp::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 Composer\Semver\Semver; 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 <2.0.0) */ public function assertIsValid( string $extensionClass, string $extensionVersion, ?string $extensionName = null, ?string $mainPluginVersionConstraint = null, ): bool { $mainPlugin = \GatoGraphQL\GatoGraphQL\PluginApp::getMainPluginManager()->getPlugin(); $mainPluginVersion = $mainPlugin->getPluginVersion(); if ( $mainPluginVersionConstraint !== null && !Semver::satisfies( $mainPluginVersion, $mainPluginVersionConstraint ) ) { $this->printAdminNoticeErrorMessage( sprintf( __('Extension or bundle <strong>%s</strong> requires plugin <strong>%s</strong> to satisfy version constraint <code>%s</code>, but the current version <code>%s</code> 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( '<div class="notice notice-error"><p>%s</p></div>', $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 Symplify\MonorepoBuilder\Config\MBConfig; use Symplify\MonorepoBuilder\Release\ReleaseWorker\AddTagToChangelogReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushNextDevReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushTagReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetCurrentMutualDependenciesReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetNextMutualDependenciesReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\TagVersionReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateBranchAliasReleaseWorker; use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateReplaceReleaseWorker; 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 Nette\Utils\Strings; use PharIo\Version\Version; use Symplify\SmartFileSystem\SmartFileInfo; use Symplify\SmartFileSystem\SmartFileSystem; 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<string,string> $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 插件具有可扩展性吗?请在评论区告诉我们。
评论留言