探索及构建支持付费升级的WordPress免费插件
在 WordPress 生态系统中,采用免费模式是商业插件推广和盈利的一种普遍方法。这种方法需要免费发布插件的基本版本,通常是通过 WordPress 插件目录发布,然后知识兔通过专业版或附加组件(通常在插件网站上销售)分享增强功能。
在免费模式中整合商业功能有三种不同的方法:
- 在免费插件中搭载这些商业功能,只有在网站上安装了商业版本或分享了商业许可密钥时才能激活这些功能。
- 将免费版和专业版创建为独立插件,专业版旨在取代免费版,确保任何时候都只安装一个版本。
- 在安装免费插件的同时安装专业版,扩展其功能。这需要两个版本都存在。
但是,第一种方法不符合通过 WordPress 插件目录发布插件的指导原则,因为这些规则禁止在付费或升级之前加入受限或锁定的功能。
这样,我们就有了后两种选择,它们各有利弊。下文将解释为什么后一种策略,即 “免费基础上的专业”,是我们的最佳选择。
让我们深入探讨第二个方案,即 “专业版取代免费版”,它的缺陷,以及为什么最终不推荐它。
随后,我们将深入探讨 “免费基础上的专业版”,重点说明为什么它是首选。
“专业版取代免费版” 策略的优势
“专业版取代免费版” 的策略实施起来相对容易,因为开发人员可以为两个插件(免费版和专业版)使用一个代码库,并从中创建两个输出,免费版(或 “标准” 版)只需包含一个代码子集,而专业版则包含所有代码。
例如,项目的代码库可以分成 standard/
和 pro/
两个目录。插件将始终加载标准代码,并根据相应目录的存在情况有条件地加载专业版代码:
// Main plugin file: myplugin.php// Always load the standard plugin's coderequire_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.zip
和 myplugin-pro.zip
。
如果知识兔将项目托管在 GitHub 上,并通过 GitHub Actions 生成资产,则可按照以下工作流程完成工作:
name: Generate the standard and PRO pluginson:release:types: [published]jobs:process:name: Generate pluginsruns-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Install zipuses: 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 filerun: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip- name: Upload both plugins to the release pageuses: softprops/action-gh-release@v1with:files: |myplugin-standard.zipmyplugin-pro.zipenv:GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
“专业版取代免费版” 策略的问题
“专业版取代免费版” 策略要求用专业版取代免费版插件。因此,如果知识兔免费插件是通过 WordPress 插件目录发布的,其 “有效安装” 计数就会下降(因为它只跟踪免费插件,而不是专业版),给人的印象是该插件没有实际那么受欢迎。
这种结果将违背使用 WordPress 插件目录的初衷: 作为一个插件发现渠道,用户可以发现我们的插件,下载并安装它。(安装完成后,免费插件可以邀请用户升级到专业版)。
如果知识兔主动安装次数不多,用户可能不会被说服安装我们的插件。例如,Newsletter Glue 插件的所有者决定将该插件从 WordPress 插件目录中删除,因为低激活数损害了该插件的前景。
既然 “专业版取代免费版” 的策略行不通,那么我们就只有一个选择:”免费基础上的专业版”。
让我们来探讨一下这种策略的来龙去脉。
构思 “免费基础上的专业版” 策略
其理念是在网站上安装免费插件,并通过安装其他插件或附加组件来扩展其功能。这可以通过单个专业版插件,知识兔也可以通过一系列专业版扩展或附加组件来实现,其中每一个都能分享某些特定的功能。
在这种情况下,免费插件并不关心网站上安装了哪些其他插件。它所做的只是分享额外的功能。这种模式具有多功能性,允许原始开发者和第三方创建者进行扩展,形成一个生态系统,使插件可以向不可预见的方向发展。
请注意,PRO 扩展是由我们(即开发标准插件的同一批开发人员)制作,还是由其他人制作并不重要:处理两者的代码是一样的。因此,创建一个不限制插件扩展方式的基础是一个好主意。这将使第三方开发人员以我们未曾想到的方式扩展我们的插件成为可能。
设计方法:钩子和服务容器
让 PHP 代码具有可扩展性有两种主要方法:
前一种方法是 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 可通过过滤器覆盖:
customer->get_downloadable_products();$has_downloads = (bool) $downloads;do_action( 'woocommerce_before_account_downloads', $has_downloads ); ?>' . esc_html__( 'Browse products', 'woocommerce' ) . '', 'notice' );?>
通过服务容器使代码具有可扩展性
服务容器是一种 PHP 对象,可帮助我们管理项目中所有类的实例化,通常作为 “依赖注入” 库的一部分分享。
依赖注入是一种策略,能以分散的方式将应用程序的所有部分粘合在一起: PHP 类通过配置注入应用程序,而应用程序则通过服务容器检索这些 PHP 类的实例。
有很多依赖注入库可用。以下是一些流行的库,由于它们都符合 PSR-11(PHP 标准建议)中关于依赖注入容器的描述,因此可以互换:
使用依赖注入,免费插件无需事先知道运行时存在哪些 PHP 类: 它只需向服务容器请求所有类的实例。许多 PHP 类由免费插件本身分享,以满足其功能,而其他类则由网站上安装的附加组件分享,以扩展功能。
使用服务容器的一个好例子是 Gato GraphQL,它依赖于 Symfony 的 DependencyInjection 库。
这就是服务容器的实例化方式:
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 instanceif (!$this->cached) {$this->instance = new ContainerBuilder();} else {require_once $this->cacheFile;/** @var class-string*/$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 containerif (!$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 processeschmod($this->cacheFile, 0777);}}
请注意,服务容器(可在类为 GatoGraphQL\ServiceContainer
的 PHP 对象下访问)会在第一次执行插件时生成,然后知识兔缓存到磁盘(作为文件 container.php
存入系统临时文件夹)。这是因为生成服务容器是一个昂贵的过程,可能需要几秒钟才能完成。
然后知识兔,主插件及其所有扩展都会通过配置文件定义要注入容器的服务:
services:_defaults:public: trueautowire: trueautoconfigure: trueGatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistryInterface:class: \GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistryGatoGraphQL\GatoGraphQL\Log\LoggerInterface:class: \GatoGraphQL\GatoGraphQL\Log\LoggerGatoGraphQL\GatoGraphQL\Services\:resource: ../src/Services/*GatoGraphQL\GatoGraphQL\State\:resource: '../src/State/*'
请注意,我们可以实例化特定类的对象(如通过合约接口 GatoGraphQL\GatoGraphQL\LogLoggerInterface
访问的 GatoGraphQL\GatoGraphQL\LogLogger
),我们也可以指明 “实例化某个目录下的所有类”(如 ../src/Services
下的所有服务)。
最后,我们将配置注入服务容器:
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 执行初始化逻辑:
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*/protected function getCustomPostTypeArgs(): array{/** @var array */$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 */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 中:
该类存在于免费插件中,PRO 扩展分享了其他自定义文章类型类,同样是从
AbstractCustomPostType
扩展而来。比较钩子和服务容器
让我们比较一下这两种设计方法。
对于动作和过滤器钩子来说,这是一种更简单的方法,其功能是 WordPress 核心的一部分。任何使用 WordPress 的开发人员都已经知道如何处理钩子,因此学习曲线较低。
不过,它的逻辑与钩子名称相连,而钩子名称是一个字符串,因此可能会导致错误: 如果知识兔钩子名称被修改,就会破坏扩展的逻辑。然而,开发人员可能不会注意到问题的存在,因为 PHP 代码仍然可以编译。
因此,废弃的钩子往往会在代码库中保留很长一段时间,甚至可能永远保留。这样,项目中就会积累一些陈旧的代码,由于担心会破坏扩展而无法删除。
回到 WooCommerce,
dashboard.php
文件就证明了这种情况(注意从2.6
版开始就保留了废弃钩子,而当前的最新版本是8.5
):使用服务容器的缺点是需要外部库,这进一步增加了复杂性。此外,还必须对该库进行范围限定(使用 PHP-Scoper 或 Strauss),以防同一网站上的其他插件安装了不同版本的相同库,从而产生冲突。
使用服务容器无疑更难实现,需要更长的开发时间。
从好的方面看,服务容器处理的是 PHP 类,无需将逻辑与某些字符串耦合。这将使项目使用更多的 PHP 最佳实践,从而使代码库更易于长期维护。
下载仅供下载体验和测试学习,不得商用和正当使用。