探索及構建支援付費升級的WordPress免費外掛

探索及構建支援付費升級的WordPress免費外掛

在 WordPress 生態系統中,採用免費模式是商業外掛推廣和盈利的一種普遍方法。這種方法需要免費釋出外掛的基本版本,通常是通過 WordPress 外掛目錄釋出,然後通過專業版或附加元件(通常在外掛網站上銷售)提供增強功能。

在免費模式中整合商業功能有三種不同的方法:

  1. 在免費外掛中搭載這些商業功能,只有在網站上安裝了商業版本或提供了商業許可金鑰時才能啟用這些功能。
  2. 將免費版和專業版建立為獨立外掛,專業版旨在取代免費版,確保任何時候都只安裝一個版本。
  3. 在安裝免費外掛的同時安裝專業版,擴充套件其功能。這需要兩個版本都存在。

但是,第一種方法不符合通過 WordPress 外掛目錄釋出外掛的指導原則,因為這些規則禁止在付費或升級之前加入受限或鎖定的功能。

這樣,我們就有了後兩種選擇,它們各有利弊。下文將解釋為什麼後一種策略,即 “免費基礎上的專業”,是我們的最佳選擇。

讓我們深入探討第二個方案,即 “專業版取代免費版”,它的缺陷,以及為什麼最終不推薦它。

隨後,我們將深入探討 “免費基礎上的專業版”,重點說明為什麼它是首選。

“專業版取代免費版” 策略的優勢

“專業版取代免費版” 的策略實施起來相對容易,因為開發人員可以為兩個外掛(免費版和專業版)使用一個程式碼庫,並從中建立兩個輸出,免費版(或 “標準” 版)只需包含一個程式碼子集,而專業版則包含所有程式碼。

例如,專案的程式碼庫可以分成 standard/pro/ 兩個目錄。外掛將始終載入標準程式碼,並根據相應目錄的存在情況有條件地載入專業版程式碼:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Main plugin file: myplugin.php
// Always load the standard plugin's code
require_once __DIR__ . '/standard/load.php';
// Load the PRO plugin's code only if the folder exists
$proFolder = __DIR__ . '/pro';
if (file_exists($proFolder)) {
require_once $proFolder . '/load.php';
}
// Main plugin file: myplugin.php // Always load the standard plugin's code require_once __DIR__ . '/standard/load.php'; // Load the PRO plugin's code only if the folder exists $proFolder = __DIR__ . '/pro'; if (file_exists($proFolder)) { require_once $proFolder . '/load.php'; }
// Main plugin file: myplugin.php
// Always load the standard plugin's code
require_once __DIR__ . '/standard/load.php';
// Load the PRO plugin's code only if the folder exists
$proFolder = __DIR__ . '/pro';
if (file_exists($proFolder)) {
require_once $proFolder . '/load.php';
}

然後,在通過持續整合工具生成外掛時,我們可以從相同的原始碼中建立兩個資產: myplugin-standard.zipmyplugin-pro.zip

如果將專案託管在 GitHub 上,並通過 GitHub Actions 生成資產,則可按照以下工作流程完成工作:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
name: Generate the standard and PRO plugins
on:
release:
types: [published]
jobs:
process:
name: Generate plugins
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install zip
uses: montudor/action-zip@v1.0.0
- name: Create the standard plugin .zip file (excluding all PRO code)
run: zip -X -r myplugin-standard.zip . -x **/src/\pro/\*
- name: Create the PRO plugin .zip file
run: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip
- name: Upload both plugins to the release page
uses: softprops/action-gh-release@v1
with:
files: |
myplugin-standard.zip
myplugin-pro.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Generate the standard and PRO plugins on: release: types: [published] jobs: process: name: Generate plugins runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Install zip uses: montudor/action-zip@v1.0.0 - name: Create the standard plugin .zip file (excluding all PRO code) run: zip -X -r myplugin-standard.zip . -x **/src/\pro/\* - name: Create the PRO plugin .zip file run: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip - name: Upload both plugins to the release page uses: softprops/action-gh-release@v1 with: files: | myplugin-standard.zip myplugin-pro.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Generate the standard and PRO plugins
on:
release:
types: [published]
jobs:
process:
name: Generate plugins
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install zip
uses: montudor/action-zip@v1.0.0
- name: Create the standard plugin .zip file (excluding all PRO code)
run: zip -X -r myplugin-standard.zip . -x **/src/\pro/\*
- name: Create the PRO plugin .zip file
run: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip
- name: Upload both plugins to the release page
uses: softprops/action-gh-release@v1
with:
files: |
myplugin-standard.zip
myplugin-pro.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

“專業版取代免費版” 策略的問題

“專業版取代免費版” 策略要求用專業版取代免費版外掛。因此,如果免費外掛是通過 WordPress 外掛目錄釋出的,其 “有效安裝” 計數就會下降(因為它只跟蹤免費外掛,而不是專業版),給人的印象是該外掛沒有實際那麼受歡迎。

這種結果將違背使用 WordPress 外掛目錄的初衷: 作為一個外掛發現渠道,使用者可以發現我們的外掛,下載並安裝它。(安裝完成後,免費外掛可以邀請使用者升級到專業版)。

如果主動安裝次數不多,使用者可能不會被說服安裝我們的外掛。例如,Newsletter Glue 外掛的所有者決定將該外掛從 WordPress 外掛目錄中刪除,因為低啟用數損害了該外掛的前景。

既然 “專業版取代免費版” 的策略行不通,那麼我們就只有一個選擇:”免費基礎上的專業版”。

讓我們來探討一下這種策略的來龍去脈。

構思 “免費基礎上的專業版” 策略

其理念是在網站上安裝免費外掛,並通過安裝其他外掛或附加元件來擴充套件其功能。這可以通過單個專業版外掛,也可以通過一系列專業版擴充套件或附加元件來實現,其中每一個都能提供某些特定的功能。

在這種情況下,免費外掛並不關心網站上安裝了哪些其他外掛。它所做的只是提供額外的功能。這種模式具有多功能性,允許原始開發者和第三方建立者進行擴充套件,形成一個生態系統,使外掛可以向不可預見的方向發展。

請注意,PRO 擴充套件是由我們(即開發標準外掛的同一批開發人員)製作,還是由其他人制作並不重要:處理兩者的程式碼是一樣的。因此,建立一個不限制外掛擴充套件方式的基礎是一個好主意。這將使第三方開發人員以我們未曾想到的方式擴充套件我們的外掛成為可能。

設計方法:鉤子和服務容器

讓 PHP 程式碼具有可擴充套件性有兩種主要方法:

  1. 通過 WordPress 動作和過濾器鉤子
  2. 通過服務容器

前一種方法是 WordPress 開發人員最常用的方法,而後一種方法則是廣大 PHP 社羣的首選。

讓我們看看這兩種方法的例子。

通過動作和過濾器鉤子使程式碼具有可擴充套件性

WordPress 提供鉤子(過濾器和動作)作為修改行為的機制。過濾器鉤子用於覆蓋值,動作鉤子用於執行自定義功能。

這樣,我們的主外掛就可以在整個程式碼庫中 “遍佈” 鉤子,讓開發人員可以修改其行為。

WooCommerce 就是一個很好的例子,它擁有一個龐大的附加元件生態系統,其中大部分都歸第三方供應商所有。這要歸功於該外掛提供的大量鉤子

WooCommerce 的開發人員特意新增了鉤子,儘管他們自己並不需要這些鉤子。這是給其他人使用的。請注意大量的 “before” 和 “after” 動作鉤子:

  • woocommerce_after_account_downloads
  • woocommerce_after_account_navigation
  • woocommerce_after_account_orders
  • woocommerce_after_account_payment_methods
  • woocommerce_after_available_downloads
  • woocommerce_after_cart
  • woocommerce_after_cart_contents
  • woocommerce_after_cart_item_name
  • woocommerce_after_cart_table
  • woocommerce_after_cart_totals
  • woocommerce_before_account_downloads
  • woocommerce_before_account_navigation
  • woocommerce_before_account_orders
  • woocommerce_before_account_orders_pagination
  • woocommerce_before_account_payment_methods
  • woocommerce_before_available_downloads
  • woocommerce_before_cart
  • woocommerce_before_cart_collaterals
  • woocommerce_before_cart_contents
  • woocommerce_before_cart_table
  • woocommerce_before_cart_totals

例如, downloads.php 檔案包含多個可注入額外功能的操作,商店 URL 可通過過濾器覆蓋:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<?php
$downloads = WC()->customer->get_downloadable_products();
$has_downloads = (bool) $downloads;
do_action( 'woocommerce_before_account_downloads', $has_downloads ); ?>
<?php if ( $has_downloads ) : ?>
<?php do_action( 'woocommerce_before_available_downloads' ); ?>
<?php do_action( 'woocommerce_available_downloads', $downloads ); ?>
<?php do_action( 'woocommerce_after_available_downloads' ); ?>
<?php else : ?>
<?php
$wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '';
wc_print_notice( esc_html__( 'No downloads available yet.', 'woocommerce' ) . ' <a class="button wc-forward' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' );
?>
<?php endif; ?>
<?php do_action( 'woocommerce_after_account_downloads', $has_downloads ); ?>
<?php $downloads = WC()->customer->get_downloadable_products(); $has_downloads = (bool) $downloads; do_action( 'woocommerce_before_account_downloads', $has_downloads ); ?> <?php if ( $has_downloads ) : ?> <?php do_action( 'woocommerce_before_available_downloads' ); ?> <?php do_action( 'woocommerce_available_downloads', $downloads ); ?> <?php do_action( 'woocommerce_after_available_downloads' ); ?> <?php else : ?> <?php $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : ''; wc_print_notice( esc_html__( 'No downloads available yet.', 'woocommerce' ) . ' <a class="button wc-forward' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' ); ?> <?php endif; ?> <?php do_action( 'woocommerce_after_account_downloads', $has_downloads ); ?>
<?php
$downloads     = WC()->customer->get_downloadable_products();
$has_downloads = (bool) $downloads;
do_action( 'woocommerce_before_account_downloads', $has_downloads ); ?>
<?php if ( $has_downloads ) : ?>
<?php do_action( 'woocommerce_before_available_downloads' ); ?>
<?php do_action( 'woocommerce_available_downloads', $downloads ); ?>
<?php do_action( 'woocommerce_after_available_downloads' ); ?>
<?php else : ?>
<?php
$wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '';
wc_print_notice( esc_html__( 'No downloads available yet.', 'woocommerce' ) . ' <a class="button wc-forward' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' );
?>
<?php endif; ?>
<?php do_action( 'woocommerce_after_account_downloads', $has_downloads ); ?>

通過服務容器使程式碼具有可擴充套件性

服務容器是一種 PHP 物件,可幫助我們管理專案中所有類的例項化,通常作為 “依賴注入” 庫的一部分提供。

依賴注入是一種策略,能以分散的方式將應用程式的所有部分粘合在一起: PHP 類通過配置注入應用程式,而應用程式則通過服務容器檢索這些 PHP 類的例項。

有很多依賴注入庫可用。以下是一些流行的庫,由於它們都符合 PSR-11(PHP 標準建議)中關於依賴注入容器的描述,因此可以互換:

Laravel 還包含一個服務容器,已嵌入應用程式。

使用依賴注入,免費外掛無需事先知道執行時存在哪些 PHP 類: 它只需向服務容器請求所有類的例項。許多 PHP 類由免費外掛本身提供,以滿足其功能,而其他類則由網站上安裝的附加元件提供,以擴充套件功能。

使用服務容器的一個好例子是 Gato GraphQL,它依賴於 Symfony 的 DependencyInjection 庫。

這就是服務容器的例項化方式

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<?php
declare(strict_types=1);
namespace GatoGraphQL\Container;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
trait ContainerBuilderFactoryTrait
{
protected ContainerInterface $instance;
protected bool $cacheContainerConfiguration;
protected bool $cached;
protected string $cacheFile;
/**
* Initialize the Container Builder.
* If the directory is not provided, store the
* cache in a system temp dir
*/
public function init(
bool $cacheContainerConfiguration,
string $namespace,
string $directory
): void {
$this->cacheContainerConfiguration = $cacheContainerConfiguration;
if ($this->cacheContainerConfiguration) {
if (!$directory) {
$directory = sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'container-cache';
}
$directory .= \DIRECTORY_SEPARATOR . $namespace;
if (!is_dir($directory)) {
@mkdir($directory, 0777, true);
}
// Store the cache under this file
$this->cacheFile = $directory . 'container.php';
$containerConfigCache = new ConfigCache($this->cacheFile, false);
$this->cached = $containerConfigCache->isFresh();
} else {
$this->cached = false;
}
// If not cached, then create the new instance
if (!$this->cached) {
$this->instance = new ContainerBuilder();
} else {
require_once $this->cacheFile;
/** @var class-string<ContainerBuilder> */
$containerFullyQuantifiedClass = "\\GatoGraphQL\\ServiceContainer";
$this->instance = new $containerFullyQuantifiedClass();
}
}
public function getInstance(): ContainerInterface
{
return $this->instance;
}
/**
* If the container is not cached, then compile it and cache it
*
* @param CompilerPassInterface[] $compilerPasses Compiler Pass objects to register on the container
*/
public function maybeCompileAndCacheContainer(
array $compilerPasses = []
): void {
/**
* Compile Symfony's DependencyInjection Container Builder.
*
* After compiling, cache it in disk for performance.
*
* This happens only the first time the site is accessed
* on the current server.
*/
if ($this->cached) {
return;
}
/** @var ContainerBuilder */
$containerBuilder = $this->getInstance();
foreach ($compilerPasses as $compilerPass) {
$containerBuilder->addCompilerPass($compilerPass);
}
// Compile the container.
$containerBuilder->compile();
// Cache the container
if (!$this->cacheContainerConfiguration) {
return;
}
// Create the folder if it doesn't exist, and check it was successful
$dir = dirname($this->cacheFile);
$folderExists = file_exists($dir);
if (!$folderExists) {
$folderExists = @mkdir($dir, 0777, true);
if (!$folderExists) {
return;
}
}
// Save the container to disk
$dumper = new PhpDumper($containerBuilder);
file_put_contents(
$this->cacheFile,
$dumper->dump(
[
'class' => 'ServiceContainer',
'namespace' => 'GatoGraphQL',
]
)
);
// Change the permissions so it can be modified by external processes
chmod($this->cacheFile, 0777);
}
}
<?php declare(strict_types=1); namespace GatoGraphQL\Container; use Symfony\Component\Config\ConfigCache; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Dumper\PhpDumper; trait ContainerBuilderFactoryTrait { protected ContainerInterface $instance; protected bool $cacheContainerConfiguration; protected bool $cached; protected string $cacheFile; /** * Initialize the Container Builder. * If the directory is not provided, store the * cache in a system temp dir */ public function init( bool $cacheContainerConfiguration, string $namespace, string $directory ): void { $this->cacheContainerConfiguration = $cacheContainerConfiguration; if ($this->cacheContainerConfiguration) { if (!$directory) { $directory = sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'container-cache'; } $directory .= \DIRECTORY_SEPARATOR . $namespace; if (!is_dir($directory)) { @mkdir($directory, 0777, true); } // Store the cache under this file $this->cacheFile = $directory . 'container.php'; $containerConfigCache = new ConfigCache($this->cacheFile, false); $this->cached = $containerConfigCache->isFresh(); } else { $this->cached = false; } // If not cached, then create the new instance if (!$this->cached) { $this->instance = new ContainerBuilder(); } else { require_once $this->cacheFile; /** @var class-string<ContainerBuilder> */ $containerFullyQuantifiedClass = "\\GatoGraphQL\\ServiceContainer"; $this->instance = new $containerFullyQuantifiedClass(); } } public function getInstance(): ContainerInterface { return $this->instance; } /** * If the container is not cached, then compile it and cache it * * @param CompilerPassInterface[] $compilerPasses Compiler Pass objects to register on the container */ public function maybeCompileAndCacheContainer( array $compilerPasses = [] ): void { /** * Compile Symfony's DependencyInjection Container Builder. * * After compiling, cache it in disk for performance. * * This happens only the first time the site is accessed * on the current server. */ if ($this->cached) { return; } /** @var ContainerBuilder */ $containerBuilder = $this->getInstance(); foreach ($compilerPasses as $compilerPass) { $containerBuilder->addCompilerPass($compilerPass); } // Compile the container. $containerBuilder->compile(); // Cache the container if (!$this->cacheContainerConfiguration) { return; } // Create the folder if it doesn't exist, and check it was successful $dir = dirname($this->cacheFile); $folderExists = file_exists($dir); if (!$folderExists) { $folderExists = @mkdir($dir, 0777, true); if (!$folderExists) { return; } } // Save the container to disk $dumper = new PhpDumper($containerBuilder); file_put_contents( $this->cacheFile, $dumper->dump( [ 'class' => 'ServiceContainer', 'namespace' => 'GatoGraphQL', ] ) ); // Change the permissions so it can be modified by external processes chmod($this->cacheFile, 0777); } }
<?php
declare(strict_types=1);
namespace GatoGraphQL\Container;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
trait ContainerBuilderFactoryTrait
{
protected ContainerInterface $instance;
protected bool $cacheContainerConfiguration;
protected bool $cached;
protected string $cacheFile;
/**
* Initialize the Container Builder.
* If the directory is not provided, store the
* cache in a system temp dir
*/
public function init(
bool $cacheContainerConfiguration,
string $namespace,
string $directory
): void {
$this->cacheContainerConfiguration = $cacheContainerConfiguration;
if ($this->cacheContainerConfiguration) {
if (!$directory) {
$directory = sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'container-cache';
}
$directory .= \DIRECTORY_SEPARATOR . $namespace;
if (!is_dir($directory)) {
@mkdir($directory, 0777, true);
}
// Store the cache under this file
$this->cacheFile = $directory . 'container.php';
$containerConfigCache = new ConfigCache($this->cacheFile, false);
$this->cached = $containerConfigCache->isFresh();
} else {
$this->cached = false;
}
// If not cached, then create the new instance
if (!$this->cached) {
$this->instance = new ContainerBuilder();
} else {
require_once $this->cacheFile;
/** @var class-string<ContainerBuilder> */
$containerFullyQuantifiedClass = "\\GatoGraphQL\\ServiceContainer";
$this->instance = new $containerFullyQuantifiedClass();
}
}
public function getInstance(): ContainerInterface
{
return $this->instance;
}
/**
* If the container is not cached, then compile it and cache it
*
* @param CompilerPassInterface[] $compilerPasses Compiler Pass objects to register on the container
*/
public function maybeCompileAndCacheContainer(
array $compilerPasses = []
): void {
/**
* Compile Symfony's DependencyInjection Container Builder.
*
* After compiling, cache it in disk for performance.
*
* This happens only the first time the site is accessed
* on the current server.
*/
if ($this->cached) {
return;
}
/** @var ContainerBuilder */
$containerBuilder = $this->getInstance();
foreach ($compilerPasses as $compilerPass) {
$containerBuilder->addCompilerPass($compilerPass);
}
// Compile the container.
$containerBuilder->compile();
// Cache the container
if (!$this->cacheContainerConfiguration) {
return;
}
// Create the folder if it doesn't exist, and check it was successful
$dir = dirname($this->cacheFile);
$folderExists = file_exists($dir);
if (!$folderExists) {
$folderExists = @mkdir($dir, 0777, true);
if (!$folderExists) {
return;
}
}
// Save the container to disk
$dumper = new PhpDumper($containerBuilder);
file_put_contents(
$this->cacheFile,
$dumper->dump(
[
'class' => 'ServiceContainer',
'namespace' => 'GatoGraphQL',
]
)
);
// Change the permissions so it can be modified by external processes
chmod($this->cacheFile, 0777);
}
}

請注意,服務容器(可在類為 GatoGraphQL\ServiceContainer 的 PHP 物件下訪問)會在第一次執行外掛時生成,然後快取到磁碟(作為檔案 container.php 存入系統臨時資料夾)。這是因為生成服務容器是一個昂貴的過程,可能需要幾秒鐘才能完成。

然後,主外掛及其所有擴充套件都會通過配置檔案定義要注入容器的服務

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
services:
_defaults:
public: true
autowire: true
autoconfigure: true
GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistryInterface:
class: \GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistry
GatoGraphQL\GatoGraphQL\Log\LoggerInterface:
class: \GatoGraphQL\GatoGraphQL\Log\Logger
GatoGraphQL\GatoGraphQL\Services\:
resource: ../src/Services/*
GatoGraphQL\GatoGraphQL\State\:
resource: '../src/State/*'
services: _defaults: public: true autowire: true autoconfigure: true GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistryInterface: class: \GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistry GatoGraphQL\GatoGraphQL\Log\LoggerInterface: class: \GatoGraphQL\GatoGraphQL\Log\Logger GatoGraphQL\GatoGraphQL\Services\: resource: ../src/Services/* GatoGraphQL\GatoGraphQL\State\: resource: '../src/State/*'
services:
_defaults:
public: true
autowire: true
autoconfigure: true
GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistryInterface:
class: \GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistry
GatoGraphQL\GatoGraphQL\Log\LoggerInterface:
class: \GatoGraphQL\GatoGraphQL\Log\Logger
GatoGraphQL\GatoGraphQL\Services\:
resource: ../src/Services/*
GatoGraphQL\GatoGraphQL\State\:
resource: '../src/State/*'

請注意,我們可以例項化特定類的物件(如通過合約介面 GatoGraphQL\GatoGraphQL\LogLoggerInterface 訪問的 GatoGraphQL\GatoGraphQL\LogLogger),我們也可以指明 “例項化某個目錄下的所有類”(如 ../src/Services 下的所有服務)。

最後,我們將配置注入服務容器

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<?php
declare(strict_types=1);
namespace PoP\Root\Module;
use PoP\Root\App;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
trait InitializeContainerServicesInModuleTrait
{
// Initialize the services defined in the YAML configuration file.
public function initServices(
string $dir,
string $serviceContainerConfigFileName
): void {
// First check if the container has been cached. If so, do nothing
if (App::getContainerBuilderFactory()->isCached()) {
return;
}
// Initialize the ContainerBuilder with this module's service implementations
/** @var ContainerBuilder */
$containerBuilder = App::getContainer();
$loader = new YamlFileLoader($containerBuilder, new FileLocator($dir));
$loader->load($serviceContainerConfigFileName);
}
}
<?php declare(strict_types=1); namespace PoP\Root\Module; use PoP\Root\App; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; trait InitializeContainerServicesInModuleTrait { // Initialize the services defined in the YAML configuration file. public function initServices( string $dir, string $serviceContainerConfigFileName ): void { // First check if the container has been cached. If so, do nothing if (App::getContainerBuilderFactory()->isCached()) { return; } // Initialize the ContainerBuilder with this module's service implementations /** @var ContainerBuilder */ $containerBuilder = App::getContainer(); $loader = new YamlFileLoader($containerBuilder, new FileLocator($dir)); $loader->load($serviceContainerConfigFileName); } }
<?php
declare(strict_types=1);
namespace PoP\Root\Module;
use PoP\Root\App;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
trait InitializeContainerServicesInModuleTrait
{
// Initialize the services defined in the YAML configuration file.
public function initServices(
string $dir,
string $serviceContainerConfigFileName
): void {
// First check if the container has been cached. If so, do nothing
if (App::getContainerBuilderFactory()->isCached()) {
return;
}
// Initialize the ContainerBuilder with this module's service implementations
/** @var ContainerBuilder */
$containerBuilder = App::getContainer();
$loader = new YamlFileLoader($containerBuilder, new FileLocator($dir));
$loader->load($serviceContainerConfigFileName);
}
}

注入容器的服務可配置為始終初始化或僅在請求時初始化(懶惰模式)。

例如,為了表示自定義帖子型別,外掛有一個 AbstractCustomPostType 類,其 initialize 方法根據 WordPress 執行初始化邏輯:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<?php
declare(strict_types=1);
namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes;
use GatoGraphQL\GatoGraphQL\Services\Taxonomies\TaxonomyInterface;
use PoP\Root\Services\AbstractAutomaticallyInstantiatedService;
abstract class AbstractCustomPostType extends AbstractAutomaticallyInstantiatedService implements CustomPostTypeInterface
{
public function initialize(): void
{
\add_action(
'init',
$this->initCustomPostType(...)
);
}
/**
* Register the post type
*/
public function initCustomPostType(): void
{
\register_post_type($this->getCustomPostType(), $this->getCustomPostTypeArgs());
}
abstract public function getCustomPostType(): string;
/**
* Arguments for registering the post type
*
* @return array<string,mixed>
*/
protected function getCustomPostTypeArgs(): array
{
/** @var array<string,mixed> */
$postTypeArgs = [
'public' => $this->isPublic(),
'publicly_queryable' => $this->isPubliclyQueryable(),
'label' => $this->getCustomPostTypeName(),
'labels' => $this->getCustomPostTypeLabels($this->getCustomPostTypeName(), $this->getCustomPostTypePluralNames(true), $this->getCustomPostTypePluralNames(false)),
'capability_type' => 'post',
'hierarchical' => $this->isAPIHierarchyModuleEnabled() && $this->isHierarchical(),
'exclude_from_search' => true,
'show_in_admin_bar' => $this->showInAdminBar(),
'show_in_nav_menus' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true,
];
return $postTypeArgs;
}
/**
* Labels for registering the post type
*
* @param string $name_uc Singular name uppercase
* @param string $names_uc Plural name uppercase
* @param string $names_lc Plural name lowercase
* @return array<string,string>
*/
protected function getCustomPostTypeLabels(string $name_uc, string $names_uc, string $names_lc): array
{
return array(
'name' => $names_uc,
'singular_name' => $name_uc,
'add_new' => sprintf(\__('Add New %s', 'gatographql'), $name_uc),
'add_new_item' => sprintf(\__('Add New %s', 'gatographql'), $name_uc),
'edit_item' => sprintf(\__('Edit %s', 'gatographql'), $name_uc),
'new_item' => sprintf(\__('New %s', 'gatographql'), $name_uc),
'all_items' => $names_uc,//sprintf(\__('All %s', 'gatographql'), $names_uc),
'view_item' => sprintf(\__('View %s', 'gatographql'), $name_uc),
'search_items' => sprintf(\__('Search %s', 'gatographql'), $names_uc),
'not_found' => sprintf(\__('No %s found', 'gatographql'), $names_lc),
'not_found_in_trash' => sprintf(\__('No %s found in Trash', 'gatographql'), $names_lc),
'parent_item_colon' => sprintf(\__('Parent %s:', 'gatographql'), $name_uc),
);
}
}
<?php declare(strict_types=1); namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes; use GatoGraphQL\GatoGraphQL\Services\Taxonomies\TaxonomyInterface; use PoP\Root\Services\AbstractAutomaticallyInstantiatedService; abstract class AbstractCustomPostType extends AbstractAutomaticallyInstantiatedService implements CustomPostTypeInterface { public function initialize(): void { \add_action( 'init', $this->initCustomPostType(...) ); } /** * Register the post type */ public function initCustomPostType(): void { \register_post_type($this->getCustomPostType(), $this->getCustomPostTypeArgs()); } abstract public function getCustomPostType(): string; /** * Arguments for registering the post type * * @return array<string,mixed> */ protected function getCustomPostTypeArgs(): array { /** @var array<string,mixed> */ $postTypeArgs = [ 'public' => $this->isPublic(), 'publicly_queryable' => $this->isPubliclyQueryable(), 'label' => $this->getCustomPostTypeName(), 'labels' => $this->getCustomPostTypeLabels($this->getCustomPostTypeName(), $this->getCustomPostTypePluralNames(true), $this->getCustomPostTypePluralNames(false)), 'capability_type' => 'post', 'hierarchical' => $this->isAPIHierarchyModuleEnabled() && $this->isHierarchical(), 'exclude_from_search' => true, 'show_in_admin_bar' => $this->showInAdminBar(), 'show_in_nav_menus' => true, 'show_ui' => true, 'show_in_menu' => true, 'show_in_rest' => true, ]; return $postTypeArgs; } /** * Labels for registering the post type * * @param string $name_uc Singular name uppercase * @param string $names_uc Plural name uppercase * @param string $names_lc Plural name lowercase * @return array<string,string> */ protected function getCustomPostTypeLabels(string $name_uc, string $names_uc, string $names_lc): array { return array( 'name' => $names_uc, 'singular_name' => $name_uc, 'add_new' => sprintf(\__('Add New %s', 'gatographql'), $name_uc), 'add_new_item' => sprintf(\__('Add New %s', 'gatographql'), $name_uc), 'edit_item' => sprintf(\__('Edit %s', 'gatographql'), $name_uc), 'new_item' => sprintf(\__('New %s', 'gatographql'), $name_uc), 'all_items' => $names_uc,//sprintf(\__('All %s', 'gatographql'), $names_uc), 'view_item' => sprintf(\__('View %s', 'gatographql'), $name_uc), 'search_items' => sprintf(\__('Search %s', 'gatographql'), $names_uc), 'not_found' => sprintf(\__('No %s found', 'gatographql'), $names_lc), 'not_found_in_trash' => sprintf(\__('No %s found in Trash', 'gatographql'), $names_lc), 'parent_item_colon' => sprintf(\__('Parent %s:', 'gatographql'), $name_uc), ); } }
<?php
declare(strict_types=1);
namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes;
use GatoGraphQL\GatoGraphQL\Services\Taxonomies\TaxonomyInterface;
use PoP\Root\Services\AbstractAutomaticallyInstantiatedService;
abstract class AbstractCustomPostType extends AbstractAutomaticallyInstantiatedService implements CustomPostTypeInterface
{
public function initialize(): void
{
\add_action(
'init',
$this->initCustomPostType(...)
);
}
/**
* Register the post type
*/
public function initCustomPostType(): void
{
\register_post_type($this->getCustomPostType(), $this->getCustomPostTypeArgs());
}
abstract public function getCustomPostType(): string;
/**
* Arguments for registering the post type
*
* @return array<string,mixed>
*/
protected function getCustomPostTypeArgs(): array
{
/** @var array<string,mixed> */
$postTypeArgs = [
'public' => $this->isPublic(),
'publicly_queryable' => $this->isPubliclyQueryable(),
'label' => $this->getCustomPostTypeName(),
'labels' => $this->getCustomPostTypeLabels($this->getCustomPostTypeName(), $this->getCustomPostTypePluralNames(true), $this->getCustomPostTypePluralNames(false)),
'capability_type' => 'post',
'hierarchical' => $this->isAPIHierarchyModuleEnabled() && $this->isHierarchical(),
'exclude_from_search' => true,
'show_in_admin_bar' => $this->showInAdminBar(),
'show_in_nav_menus' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true,
];
return $postTypeArgs;
}
/**
* Labels for registering the post type
*
* @param string $name_uc Singular name uppercase
* @param string $names_uc Plural name uppercase
* @param string $names_lc Plural name lowercase
* @return array<string,string>
*/
protected function getCustomPostTypeLabels(string $name_uc, string $names_uc, string $names_lc): array
{
return array(
'name'         => $names_uc,
'singular_name'    => $name_uc,
'add_new'      => sprintf(\__('Add New %s', 'gatographql'), $name_uc),
'add_new_item'     => sprintf(\__('Add New %s', 'gatographql'), $name_uc),
'edit_item'      => sprintf(\__('Edit %s', 'gatographql'), $name_uc),
'new_item'       => sprintf(\__('New %s', 'gatographql'), $name_uc),
'all_items'      => $names_uc,//sprintf(\__('All %s', 'gatographql'), $names_uc),
'view_item'      => sprintf(\__('View %s', 'gatographql'), $name_uc),
'search_items'     => sprintf(\__('Search %s', 'gatographql'), $names_uc),
'not_found'      => sprintf(\__('No %s found', 'gatographql'), $names_lc),
'not_found_in_trash' => sprintf(\__('No %s found in Trash', 'gatographql'), $names_lc),
'parent_item_colon'  => sprintf(\__('Parent %s:', 'gatographql'), $name_uc),
);
}
}

然後, GraphQLCustomEndpointCustomPostType.php 類是自定義帖子型別的實現。在作為服務注入容器後,它將被例項化並註冊到 WordPress 中:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<?php
declare(strict_types=1);
namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes;
class GraphQLCustomEndpointCustomPostType extends AbstractCustomPostType
{
public function getCustomPostType(): string
{
return 'graphql-endpoint';
}
protected function getCustomPostTypeName(): string
{
return \__('GraphQL custom endpoint', 'gatographql');
}
}
<?php declare(strict_types=1); namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes; class GraphQLCustomEndpointCustomPostType extends AbstractCustomPostType { public function getCustomPostType(): string { return 'graphql-endpoint'; } protected function getCustomPostTypeName(): string { return \__('GraphQL custom endpoint', 'gatographql'); } }
<?php
declare(strict_types=1);
namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes;
class GraphQLCustomEndpointCustomPostType extends AbstractCustomPostType
{
public function getCustomPostType(): string
{
return 'graphql-endpoint';
}
protected function getCustomPostTypeName(): string
{
return \__('GraphQL custom endpoint', 'gatographql');
}
}

該類存在於免費外掛中,PRO 擴充套件提供了其他自定義文章型別類,同樣是從 AbstractCustomPostType 擴充套件而來。

比較鉤子和服務容器

讓我們比較一下這兩種設計方法。

對於動作和過濾器鉤子來說,這是一種更簡單的方法,其功能是 WordPress 核心的一部分。任何使用 WordPress 的開發人員都已經知道如何處理鉤子,因此學習曲線較低。

不過,它的邏輯與鉤子名稱相連,而鉤子名稱是一個字串,因此可能會導致錯誤: 如果鉤子名稱被修改,就會破壞擴充套件的邏輯。然而,開發人員可能不會注意到問題的存在,因為 PHP 程式碼仍然可以編譯。

因此,廢棄的鉤子往往會在程式碼庫中保留很長一段時間,甚至可能永遠保留。這樣,專案中就會積累一些陳舊的程式碼,由於擔心會破壞擴充套件而無法刪除。

回到 WooCommerce,dashboard.php 檔案就證明了這種情況(注意從 2.6 版開始就保留了廢棄鉤子,而當前的最新版本是 8.5 ):

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<?php
/**
* My Account dashboard.
*
* @since 2.6.0
*/
do_action( 'woocommerce_account_dashboard' );
/**
* Deprecated woocommerce_before_my_account action.
*
* @deprecated 2.6.0
*/
do_action( 'woocommerce_before_my_account' );
/**
* Deprecated woocommerce_after_my_account action.
*
* @deprecated 2.6.0
*/
do_action( 'woocommerce_after_my_account' );
<?php /** * My Account dashboard. * * @since 2.6.0 */ do_action( 'woocommerce_account_dashboard' ); /** * Deprecated woocommerce_before_my_account action. * * @deprecated 2.6.0 */ do_action( 'woocommerce_before_my_account' ); /** * Deprecated woocommerce_after_my_account action. * * @deprecated 2.6.0 */ do_action( 'woocommerce_after_my_account' );
<?php
/**
* My Account dashboard.
*
* @since 2.6.0
*/
do_action( 'woocommerce_account_dashboard' );
/**
* Deprecated woocommerce_before_my_account action.
*
* @deprecated 2.6.0
*/
do_action( 'woocommerce_before_my_account' );
/**
* Deprecated woocommerce_after_my_account action.
*
* @deprecated 2.6.0
*/
do_action( 'woocommerce_after_my_account' );

使用服務容器的缺點是需要外部庫,這進一步增加了複雜性。此外,還必須對該庫進行範圍限定(使用 PHP-ScoperStrauss),以防同一網站上的其他外掛安裝了不同版本的相同庫,從而產生衝突。

使用服務容器無疑更難實現,需要更長的開發時間。

從好的方面看,服務容器處理的是 PHP 類,無需將邏輯與某些字串耦合。這將使專案使用更多的 PHP 最佳實踐,從而使程式碼庫更易於長期維護。

小結

在為 WordPress 建立外掛時,支援擴充套件是一個好主意,這樣我們(外掛的建立者)就可以提供商業功能,其他人也可以新增額外的功能,並有望形成一個以外掛為中心的生態系統。

在這篇文章中,我們探討了如何考慮 PHP 專案的架構,以使外掛具有可擴充套件性。正如我們所瞭解的,我們可以在兩種設計方法中做出選擇:使用鉤子或使用服務容器。我們對這兩種方法進行了比較,找出了各自的優點和缺點。

你打算讓你的 WordPress 外掛具有可擴充套件性嗎?請在評論區告訴我們。

評論留言