探索及构建支持付费升级的WordPress免费插件

探索及构建支持付费升级的WordPress免费插件

在 WordPress 生态系统中,采用免费模式是商业插件推广和盈利的一种普遍方法。这种方法需要免费发布插件的基本版本,通常是通过 WordPress 插件目录发布,然后通过专业版或附加组件(通常在插件网站上销售)提供增强功能。

在免费模式中整合商业功能有三种不同的方法:

  1. 在免费插件中搭载这些商业功能,只有在网站上安装了商业版本或提供了商业许可密钥时才能激活这些功能。
  2. 将免费版和专业版创建为独立插件,专业版旨在取代免费版,确保任何时候都只安装一个版本。
  3. 在安装免费插件的同时安装专业版,扩展其功能。这需要两个版本都存在。

但是,第一种方法不符合通过 WordPress 插件目录发布插件的指导原则,因为这些规则禁止在付费或升级之前加入受限或锁定的功能。

这样,我们就有了后两种选择,它们各有利弊。下文将解释为什么后一种策略,即 “免费基础上的专业”,是我们的最佳选择。

让我们深入探讨第二个方案,即 “专业版取代免费版”,它的缺陷,以及为什么最终不推荐它。

随后,我们将深入探讨 “免费基础上的专业版”,重点说明为什么它是首选。

“专业版取代免费版” 策略的优势

“专业版取代免费版” 的策略实施起来相对容易,因为开发人员可以为两个插件(免费版和专业版)使用一个代码库,并从中创建两个输出,免费版(或 “标准” 版)只需包含一个代码子集,而专业版则包含所有代码。

例如,项目的代码库可以分成 standard/pro/ 两个目录。插件将始终加载标准代码,并根据相应目录的存在情况有条件地加载专业版代码:

// 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 生成资产,则可按照以下工作流程完成工作:

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 可通过过滤器覆盖:

<?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 库。

这就是服务容器的实例化方式

<?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 存入系统临时文件夹)。这是因为生成服务容器是一个昂贵的过程,可能需要几秒钟才能完成。

然后,主插件及其所有扩展都会通过配置文件定义要注入容器的服务

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 下的所有服务)。

最后,我们将配置注入服务容器

<?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 执行初始化逻辑:

<?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 中:

<?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 ):

<?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 插件具有可扩展性吗?请在评论区告诉我们。

评论留言