calvinalkan / better-wordpress-hooks
Requires
- php: ^7.3
- calvinalkan/base-container: 0.1.5
- calvinalkan/wordpress-hook-api-clone: 5.7.2
- illuminate/support: 8.35.1
Requires (Dev)
- codeception/assert-throws: dev-master
- mockery/mockery: ^1.4.2
- php-coveralls/php-coveralls: ^2.4
- phpunit/phpunit: ^9.0
Suggests
- phpunit/phpunit: Requiered if you want to use the FakeDispatcher for testing Event Dispatching.
README
BetterWpHooks - 一个围绕 WordPress 插件 API 的现代 OOP 包装器。
BetterWpHooks 是一个小型库,它包装了 WordPress 插件/钩子 API,允许使用现代的面向对象 PHP。
包含的一些功能包括
🚀 动态实例化在动作和过滤器中注册的类。
🔥 使用 IoC 容器进行零配置的类和方法依赖关系解析。
❤ 基于运行时仅可用的参数的钩子条件和处理。
📦 内置测试模块,无需第三方模拟库或引导核心来测试钩子。
⭐ 100% 兼容 WordPress 核心,以及用户与自定义钩子和过滤器交互的方式。
目录
为什么要这么做?
WordPress 2.1 时代发布,那时 PHP 中还没有面向对象,WordPress 插件 API 有几个不足之处。
WordPress 插件/钩子 API 的主要问题包括
-
没有使用 依赖注入或 IoC 容器。如果您关心代码的质量和可维护性,您会使用 IoC 容器。
-
定义回调应该期望的参数数量很烦人,如果参数的顺序或数量发生变化,往往会引起看似随机的错误。WordPress 应该能够在幕后自己解决这个问题,但由于对向后兼容性的极端承诺,原生的 PHP 反射 API 不能使用。
-
没有 定义动作和过滤器的地方。许多 WordPress 开发者默认使用类构造函数,这并不是一个很好的选择。另一种常见方法是使用自定义工厂,这通常会导致您的 IDE 无法检测到您添加的钩子。这不是理想的选择。
-
在使用基于类的回调时,除了使用静态方法(*不要这样做)之外,唯一的选择是在创建WordPress钩子之前在每个请求上实例化类。没有任何现代PHP框架强迫您实例化类以 可能 在将来作为事件观察者使用。此外,在定义钩子时使用面向对象实践总是会导致 钩子无法被第三方开发者移除,因为WordPress将使用
spl_object_hash
来存储钩子ID。
让我们回顾一个可以在95%的流行WordPress插件中找到的例子。
// Approach #1, creating the class and passing the object to the hook. class MyClass { public function __construct() { add_action('init', [ $this, 'doStuff']); } public function doStuff () { // } public static function doStuffStatic () { // } } // Approach #2, Using static functions, worse. add_action('init', [ MyClass::class, 'doStuffStatic' ]);
对于一个简单的类来说,这可能会工作得很好,但现在想象一下,如果 MyClass
有嵌套依赖并且处理了几个WordPress钩子。在每个请求上构建这个对象根本感觉不合适,而且在未来改变构造函数参数可能会非常痛苦。
- WordPress是事件驱动的。钩子必须在每个请求上添加。然而,没有明确定义的方法可以根据您仅在运行时才可用的变量有条件地触发钩子。某些代码可能只在非常特定的条件下才需要,但类回调却在每个请求上实例化。一个例子可能是处理客户下订单总价值超过500美元时发送礼品卡的类。
//Let's assume we want to send an email and log to an external service // ( maybe a google sheet ) every time an order takes place with a total > 500. class GiftCardHandler { private $mailer; private $logger; // We need a mailer and a logger service public function __construct( Mailer $mailer, Logger $logger) { $this->mailer = $mailer; $this->logger = $logger; add_action('checkout_completed', [ $this, 'handle'], 10, 1); } //Let's assume we get an order object from the hook. public function handle (Order $order) { if ( $order->total() >= 500 ) { $mailer->sendGiftcard($order->user()); $logger->logBigBurchase($order); } } }
我们只在非常特殊的情况下使用这个类,但我们不得不在每个请求上创建它,以便将其传递给WordPress钩子。
- 最后,单元测试(是的,WordPress插件应该进行单元测试)这段代码很复杂,因为它紧密耦合到WordPress核心函数,这意味着您必须在测试设置期间启动整个WordPress安装,或者使用WordPress模拟框架,如 Brain Monkey 或 WP_Mock。我两者都使用过,它们都很棒。但是,测试基本的事件模式不应该需要走这么多弯路。
BetterWpHooks解决了所有这些问题,并为WordPress开发者提供了更多便利的功能。
要求
- BetterWpHooks是一个composer包,不是一个插件。要使用此包,您需要在插件根目录中设置composer。
- PHP版本 >= 7.3
理论上,您应该能够在经过一些小的修改后使用此包与每个PHP版本 >= 7.0,但我没有积极测试过,您也不应该使用PHP维护者不再积极支持的版本。
安装
从您的插件或主题的根目录开始,在终端中执行以下命令。
composer require calvinalkan/better-wordpress-hooks
关键概念
术语
以下WordPress操作和过滤器将被称为 事件。钩子回调,无论是类还是匿名闭包,都将被称为 事件监听器。
入口点
BetterWpHooks 的设计初衷是为了更好地适应 WordPress 插件生态系统的运作方式。与许多试图使 WordPress 现代化的其他包不同,BetterWpHooks 可以同时被无限数量的插件使用,而不会产生冲突。
访问该库的主要入口点是特质 BetterWpHooksFacade
(src)。
通过创建一个使用此特质的自定义类,您将能够访问库的核心实例。
以下内容假设我们正在一个名为 AcmePlugin 的插件中使用此库。
use BetterWpHooks\Traits\BetterWpHooksFacade; class AcmeEvents { use BetterWpHooksFacade; }
类 AcmeEvents
将是您访问库的主要类 BetterWpHooks
的实例的入口点(src),该类提供了对库的 3 个主要协作者的访问权限
1. IoC-Container
为了自动解析事件监听器的依赖关系并自动创建对象,BetterWpHooks 使用了控制反转(IoC)容器。由于许多 WordPress 插件已经使用了 IoC 容器,因此强迫使用任何特定的容器实现是没有意义的。
整个库依赖于 ContainerAdapterInterface
。默认情况下,使用 illuminate/container
的适配器。Illumiante/Container 的所有功能都得到完全支持。
可以通过确认一个简单的接口来替换实际的容器实现,因此您可以使用任何其他容器,如
唯一的要求是您的容器需要支持 自动解析(自动连接)!
2. Events
事件表示 WordPress Actions 和 Filters。然而,没有必要区分这两者。这是在底层处理的。
有两种方式可以使用 BetterWpHooks 中的事件
- 事件作为事件对象(推荐,因为它提供了更多功能)
- 类似于 WordPress Actions 和 Filters,但仍然使用 IoC 容器解析类(不推荐)
3. Event Dispatcher
这是作为您插件代码与 WordPress 插件 API 之间层的主要类。您不是直接通过 add_action
和 add_filter
创建钩子,而是通过事件调度器创建它们。
4. Event Mapper
事件映射器是一个小的类,可以用来自动将核心(或第三方)动作和过滤器转换为自定义事件对象,从而允许您即使在没有控制权的事件(钩子)上也能使用所有提供的功能。
引导
要使用 BetterWpHooks,您需要通过 3 个简单的步骤来引导它。这应该在您的核心插件文件或任何其他在 WordPress 执行第一个钩子之前执行的其他文件中完成。您还应该在您的核心插件文件中要求 composer 自动加载器。
1. 创建您的入口点类
use BetterWpHooks\Traits\BetterWpHooksFacade; class AcmeEvents { use BetterWpHooksFacade; }
2. 创建一个映射到 AcmeEvents
的 BetterWpHooks
类的实例
AcmeEvents::make(); // Alternatively if you want to use a custom container: $custom_container_adapter = new SymfonyContainerAdapter(); AcmeEvents::make($custom_container_adapter);
3. 将核心和第三方钩子映射到自定义事件对象(可选但推荐).
这所做的一切只是当WP钩子被触发时发送您的自定义事件对象。我们稍后将会看到为什么要这样做。默认情况下,映射的事件不会被从服务容器中解析,因为它们应该只包含数据类。然而,如果您将'resolve'
作为第一个键传递,您的映射事件将从容器中解析并在之后发送。
$mapped = [ // Will fire on priority 10 'init' => RegisterJobListingPostType::class, // Will fire on priority 99 'save_post_job_listing' => [JobListingCreated::class, 99], // Will be resolved from the service container // and fire on priority 99. 'booking_created' => ['resolve' ,BookingCreated::class, 99], ]; AcmeEvents::mapEvents($mapped);
4. 创建处理自定义事件的监听器并启动类。
$listeners = [ RegisterJobListingPostType::class => [ PostTypeRegistry::class, // More Event Listeners if needed ], JobListingCreated::class => [ NotifyAdmin::class, SendConfirmationEmail::class, TagCreatorInMailchimp::class // More Event Listeners if needed ], // Custom Events that you fire in your code JobMatchFound::class => [ NotifyListingOwner::class, NotifyApplicatant::class, ] ]; AcmeEvents::listeners($listeners); AcmeEvents::boot();
整个过程也可以作为流畅的API创建。.
理想情况下,您将创建一个纯PHP文件,该文件仅返回一个包含您映射的事件和事件监听器的数组。这样,您就可以在一个文件中整理所有事件,而不是在整个代码库中分散。
完整示例
require __DIR__ . '/vendor/autoload.php'; $mapped = require_once __DIR__ . '/mapped-events'; $listeners = require_once __DIR__ . '/event-listeners'; AcmeEvents::make()->mapEvents($mapped)->listeners($listeners)->boot();
使用BetterWpHooks
好吧,我为什么要这样做呢?现在是时候举一些例子了。
让我们假设我们正在开发一个Woocommerce扩展,它允许用户通过几个与营销相关的功能来扩展他们的WooCommerce商店。
其中之一是介绍中描述的礼品卡功能。
使用第三方钩子的完整示例。
我们创建了一个表示动作woocommerce_checkout_order_processed
(src)的事件。此事件需要扩展AcmeEvents
。
此动作钩子提供了创建的订单的订单_id和表单提交数据。我们将逐步创建此功能的示例,并逐步改进它。
- 创建一个事件对象。事件对象是纯PHP对象,不存储任何逻辑。
class HighValueOrderCreated extends AcmeEvents { public $order; public $submission_data; public function __construct( int $order_id, array $submission_data ) { $this->order = wc_get_order($order_id); $this->submission_data = $submission_data; } }
- 创建一个监听器,用于处理当创建的总金额 >= 500$ 的订单时的逻辑。
class ProcessGiftCards { private $mailer; private $logger; public function __construct( $mailer, $logger ) { $this->mailer = $mailer; $this->logger = $logger; } public function handleEvent ( HighValueOrderCreated $event) { $order = $event->order; // Example properties. $this->mailer->sendGiftCard( $order->user_id, $order->items ); $this->logger->logGiftcardRecepient( $order->user_id, $order->items, $order->purchased_at ); } }
- 连接事件和监听器。(连接应在单独的纯PHP文件中完成)
require __DIR__ . '/vendor/autoload.php'; $mapped = [ 'woocommerce_checkout_order_processed' => [ HighValueOrderCreated::class // Map more if needed. ]]; $listeners = [ HighValueOrderCreated::class => [ ProcessGiftCards::class . '@handleEvent' // More Listeners if needed ] ]; AcmeEvents::make()->mapEvents($mapped)->listeners($listeners)->boot();
那么当WooCommerce创建订单时会发生什么?
- WordPress将触发
woocommerce_checkout_order_processed
动作。 - 因为底层将自定义事件映射到了这个动作,WordPress现在将调用一个闭包,该闭包将使用从过滤器中传递的参数创建一个新的
HighValueOrderCreated
事件实例来构建事件对象。 - 调用的闭包首先创建实例,然后发送一个事件
HighOrderValueCreated::class
,并将创建的对象作为参数传递给任何注册的监听器。 - 由于我们为
HighOrderValueCreated
事件注册了监听器,现在将调用ProcessGiftcards
类上的handleEvent
方法。(有关详细解释,请参阅工作原理)。 - 构造函数依赖项
$mailer, $logger
将自动注入到类中。 - 如果
handleEvent()
有任何除事件对象之外的方法依赖项,那些方法依赖项也会自动注入。
改进
条件分发
您可能已经注意到,我们还没有处理基于订单值的条件执行逻辑。
- 我们为
HighValueOrderCreated
类应用了DispatchesConditionally
特性。我们还允许用户定义一个当发送礼品卡时应使用的自定义金额。
class HighValueOrderCreated extends AcmeEvents { use \BetterWpHooks\Traits\DispatchesConditionally; public $order; public $submission_data; public function __construct( int $order_id, array $submission_data ) { $this->order = wc_get_order($order_id); $this->submission_data = $submission_data; } public function shouldDispatch() : bool{ return $this->order->total >= (int) get_option('acme_gift_card_amount'); }}
在派发器派发使用此特性的事件之前,会调用并评估shouldDispatch
方法。如果它返回false,则WordPress将根本不会触发该事件。它永远不会触达插件/钩子API,且ProcessGiftCards、Mailer和Logger
的任何实例都不会被创建。
使用接口而不是具体实现。
现在我们希望用户能够选择使用不同的邮件发送服务。我们支持Sendgrid、AWS和MailGun。我们如何实现?为了不违反依赖倒置原则,我们现在使用MailerInterface
。
假设你正在使用默认的容器适配器,你现在可以这样做
use Illuminate\Container\Container; use SniccoAdapter\BaseContainerAdapter; use MailerInterface; $container = new Container(); $container->when(ProcessGiftCards::class) ->needs(MailerInterface::class) ->give(function () { $mailer = get_option('acme_mailer'); if($mailer === 'sendgrid') { return new SendGridMailer(); } if($mailer === 'mailgun') { return new MailGunMailer(); } return new AwsMailer(); }); AcmeEvents::make(new BaseContainerAdapter($container)); // ->
ProcessGiftCards
类现在将自动接收基于站点管理员当前选择的选项的正确邮件发送实现。
容器的配置应由单独的类来处理。有关所有选项和文档,请查看laravel.com上的Illuminate/Container
文档。
再次强调:如果总订单价值的最低阈值未达到,则不会执行任何操作。所有内容都是在运行时按需加载的。
我希望你们都同意,这种实现比我们目前可以通过WordPress插件/钩子API实现的任何东西都要干净,最重要的是,它更加可扩展和可维护。
派发你的事件
使用BetterWpHooks,你有两种方式可以在代码中派发事件(钩子)。
- 以对象的形式派发事件。
而不是这样做
// Code that processes an appointment booking. do_action('booking_created', $book_id, $booking_data );
你可以这样做
// Code that processes an appointment booking. BookingCreated::dispatch( [$book_id, $booking_data] );
这将首先创建一个新的BookingCreated
事件实例,将参数传递给构造函数,然后通过你的自定义类AcmeEvents
提供的Dispatcher
实例运行事件(记住所有事件都会扩展AcmeEvents
类)。
class AcmeEvents { use BetterWpHooksFacade; }
当创建对象事件时,所有应该传递给构造函数的参数都必须包装在一个数组中。
这不会起作用
BookingCreated::dispatch( $book_id, $booking_data );
只有$book_id
会被传递给构造函数。
- 通过
AcmeEvents
类派发事件
如果不想为事件创建专门的事件对象,可以使用这种方法。
// Code that processes an appointment booking. AcmeEvents::dispatch( 'booking_created', $book_id, $booking_data );
这类似于do_action()
的工作方式,但你仍然可以访问BetterWpHooks的大部分功能,如依赖自动解析和测试模块。然而,你将无法像方法#1那样使用条件事件派发。
此外,你的监听器需要接受与派发事件时传递的相同数量的参数。在方法#1中,你的监听器始终只接收一个参数,即事件对象实例。
使用外观类,你有两种定义参数的方式,它们都会产生相同的结果。
// These are identical. AcmeEvents::dispatch( 'booking_created', [ $book_id, $booking_data ] ); AcmeEvents::dispatch( 'booking_created', $book_id, $booking_data );
然而,只有第一个传入的参数必须是你要派发的事件的标识符。
派发辅助函数
有时你可能只想在特定条件下派发你的一个事件。
// If we have a big-group appointment we want to send an // email to the responsible staff to notify them in advance // This can be replaced if ( $appointment->participantCount() >= 5 ) { do_action('acme_big_group_booking_created', $appointment); } // With BigGroupBookingCreated::dispatchIf( $appointment->participantCount() >= 5, [$appointment]);
还有相反的选项
BookingCreated::dispatchUnless( $appointment->participantCount() >= 5, [$appointment]);
WordPress过滤器
使用默认的插件/钩子API时,您需要区分使用add_action
和add_filter
。BetterWpHooks会自动处理这一点。定义动作和过滤器的语法相同。让我们来看一个简单的例子,我们可能希望允许其他开发者修改由我们的虚构预约插件创建的预约数据。
// Code to create an appointment object $appointment = AppointmentCreated::dispatch([ $appointment_object ]); // Save appointment to the Database.
第三方开发者(或者可能是您插件的付费扩展)现在可以像平常一样过滤预约对象。示例
// When dispatching object events the hook id is always the full class name. add_filter('AppointmentCreated::class', function( AppointmentCreated $event) { $appointment = $event->appointment; if ( $some_conndition === TRUE ) { // increase cost. $appointment->cost = 50; } return $appointment; } );
或者您也可以这样分发过滤器
$appointment = AcmeEvents::dispatch( 'acme_appointment_created', $appointment_object );
默认返回值。
BetterWpHooks会识别您正在尝试分发过滤器,并将自动处理没有监听器来处理事件的情形。在这种情况下,将不会构建任何对象,实际上底层甚至不会调用插件API。
默认返回值按照以下顺序评估
-
如果您正在分发事件对象,您可以在事件对象类上定义一个
default()
方法。如果存在该方法,将调用它,并将返回的值作为默认值传递。 -
如果没有在事件类上定义
default()
方法,但您正在分发事件对象,将返回该对象本身。对于上面的例子,将返回AppointmentCreated
的实例。 -
如果1和2都不可能,将返回传递给
dispatch()
方法的第一个参数。
无效回调的返回值
当提供过滤器时,一个常见的问题用户不遵守您的过滤器API并返回不兼容的值。如果您正在使用对象事件,可以在default()
方法上指定期望的返回值类型。如果从过滤器返回的值是
- 与原始负载相同或
- 不是
default()
上类型提示的同一类型,则将丢弃过滤的值,并且您的事件的默认方法将同时使用负载和过滤的值调用,这样您就有机会修复问题。
示例
class MyEvent extends AcmeEvents { public function default($original_payload, $filtered_value) :array { return [$filtered_value]; } }
假设第三方开发者会像这样挂钩到您的过滤器
add_filter(MyEvent::class, function ( $event ) { return 'foo'; });
$value = MyEvent::dispatch(); // without array typehint $value = 'foo', whoops. $do_stuff = $value[0]; // with typehint on default() // $value = ['foo'];
有效的监听器
监听器,就像默认的WordPress插件/钩子API一样,可以是匿名闭包或可调用的类。以下任何选项都可以用于使用分发器创建监听器。默认情况下,如果没有指定方法,BetterWpHooks将尝试在您的监听器上调用handleEvent()
方法。
$listeners = [ Event1::class => [ // All of these class callables will work. Listener::class, Listener::class . '@foo', [ Listner::class ], [ Listener::class, 'handleEvent' ], [ Listener::class, 'handleEvent' ], [ Listener::class, 'foobar' ], [ Listener::class . '@foobarbiz' ], [ 'custom_identifier' => Listener::class . '@foo' ], [ 'custom_identifier' => Listener::class], [ 'custom_identifier' => [ Listener::class, 'foobar' ] ], // Closures function (Event1 $event ) { // Do Stuff }, 'custom_closure_key' => function (Event1 $event ) { // Do Stuff } ] ];
有关其用例,请参阅自定义标识符部分。
依赖关系解析
让我们看看BetterWpHooks如何处理依赖关系解析的复杂示例。这是假设您正在使用内置的Illuminate/Container Adapter
。对于其他容器,您可能需要在定义方法和构造函数依赖关系时有所不同。
BookingEventsListener
将处理与预订相关的各种事件(创建、删除、重新安排等)。
当预订被取消时,我们希望通知酒店所有者和客人,并且还要向booking.com发出API调用以更新我们的可用性。然而,我们只需要在EventListener的一个方法中使用BookingcomClient
,因此不要将其作为构造函数依赖项。
我们的类将如下所示
class BookingEventsListener { private $complex_mailer; public function __construct( ComplexMailerDependency $mailer ) { $this->complex_mailer = $mailer; } public function bookingCanceled (BookingCanceled $event, BookingcomClient $bookingcom_client ) { $owner = $event->booking->owner(); $guest = $event->booking->guest(); $this->complex_mailer->confirmCancelation([ $owner, $guest ]); $bookingcom_client->updateAvailablity($event->booking_id); } } class ComplexMailerDependency { private $simple_dependency; public function __construct( SimpleConstructorDependency $simple_dependency) { $this->simple_dependency = $simple_dependency; } }
当发出BookingCanceled
事件时,将发生以下情况
- 实例化了
SimpleConstructorDependency
类。 - 使用
SimpleConstructorDependency
实例化了ComplexMailerDependency
。 - 使用新的
ComplexMailerDependency
实例化了BookingEventsListener
类。 - 实例化了新的
BookingcomClient
。 - 调用了
bookingCanceled()
方法,传递了分发的事件对象和BookingcomClient
。
从分发的事件创建的方法参数应该在方法签名中首先声明,在任何其他依赖项之前。.
条件事件分发
此功能旨在用于触发不在你控制之下的操作和过滤器。(例如,核心或第三方插件钩子)。
对于你控制的事件,直接使用 dispatchIf
和 dispatchUnless
辅助函数会更简单。然而,如果你的事件何时应该分发的逻辑很复杂,可能更倾向于将此逻辑放在事件对象内部。
将第三方钩子映射到自定义事件后,你应该在你的事件类上使用 DispatchesConditionally
特性。
use BetterWpHooks\Traits\DispatchesConditionally; class HighOrderValueCreated { use DispatchesConditionally; private $order; public function __construct(Order $order ) { $this->order = $order; } public function shouldDispatch() : bool{ return $this->order->total >= 500; }}
shouldDispatch
方法的返回值会在执行任何操作之前每次进行评估。如果返回 FALSE
,WordPress 插件/API 将永远不会被调用,任何类也不会被实例化。
条件事件监听
在某些情况下,确定在运行时监听器是否应该处理事件可能很有用。
让我们考虑以下虚构的用例
每次预约时,你都想
- 通过 Slack 通知责任员工。
- 向客户发送确认邮件
- 将客户添加到外部 SaaS,如 Mailchimp。
- 如果 客户预约的预约总价值超过 300 美元,你还想通过短信通知业务所有者,以便他可以亲自服务客户。
我们如何做呢?
我们的代码会在每次创建预约时分发一个事件
// Process appointment AppointmentCreated::dispatch([$order]);
很明显,在这里条件分发事件不是选项,因为我们希望监听器 1-3 总是处理事件。
所有监听器都正确注册了 AppointmentCreated
事件。
$listeners = [ AppointmentCreated::class => [ NotifyStaffViaSlack::class, SendConfirmationEmail::class, AddToMailchimp::class, NotifyBusinessOwner::class ] ];
这就是我们可以使用 条件事件监听器 的地方
我们定义 NotifyBusinessOwner
监听器如下,使用特性 ListensConditionally
当一个监听器具有此特性时,在调用定义的方法之前(在这里是 handleEvent()
)评估 shouldHandle
的返回值。如果返回 false,则方法将不会执行,并且 $event
对象将保持不变地传递给下一个监听器(如果有)。
class NotifyBusinessOwner { use \BetterWpHooks\Traits\ListensConditionally; private $sms_client; private $config; public function __construct( SmsClient $sms_client, Config $config ) { $this->sms_client = $sms_client; $this->config = $config; } public function handleEvent ( AppointmentCreated $event ) { $business_owner_phone_number = $this->config->get('primary_phone_number'); $this->sms_client->notify($business_owner_phone_number, $event->appointment); } // Abstract method defined in the trait public function shouldHandle( AppointmentCreated $event) : bool{ return $event->appointment->totalValue() >= 300; } }
停止监听器链
如果你出于某种原因想在特定监听器之后停止所有后续监听器执行,可以让该监听器使用 StopsPropagation
特性。
注意事项: 这将删除当前请求中由 你的 BetterWpHooks
实例 注册的每个监听器。监听器注册的顺序无关紧要。首先注册使用 StopsPropagation
特性的监听器将被调用,而其他所有监听器将删除当前请求。
$listeners = [ Event1::class => [ Listener1::class, Listener2::class, Listener3::class, Listener4::class ] ];
如果 Listener4
使用了 StopsPropagation
特性,则其他所有监听器都会在当前请求期间从 Event1
中删除。
API
理论上,你不应该在启动过程中之外使用由你的 AcmeEvents
类提供的底层服务类。
如果出现这种需求,BetterWpHooks 可以让你轻松地做到这一点。
您的类 AcmeEvents
(参见入口点)作为底层服务的门面,与Laravel 门面的工作方式非常相似。
每个静态方法调用都不是静态的,而是通过 PHP 的 _callStatic()
魔法方法解析为类底层的 BetterWpHooks
实例。
存在一个专门的类 Mixin
(src),它提供 IDE 自动补全,同时也作为可用方法的文档。
容器
// getting the underlying container instance. $container = AcmeEvents::container();
事件调度器
// getting the underlying dispatcher instance. $dispatcher = AcmeEvents::dispatcher();
您可以直接使用您的 AcmeEvents
类调用调度器上的方法。对调度器方法的调用优先于对主 BetterWpHooks
类的调用。
// These two are equivalent #1 $dispatcher = AcmeEvents::dispatcher(); $dispatcher->hasListeners('event_id_to_look_for'); #2 AcmeEvents::hasListener('event_id_to_look_for');
在引导过程外直接创建监听器
AcmeEvents::listen(Event1::class, Listener::class . '@foobar');
将监听器标记为不可删除
有时第三方开发者会对插件的基础代码进行过多的篡改,并使用核心函数 remove_filter()
删除完整的回调,这在大多数情况下是可以接受的。然而,如果删除给定过滤器的含义可能并不完全明显,并且很可能会使您的插件损坏,则可以使用 unremovable()
方法而不是 listen()
方法将监听器标记为不可删除。
现在,监听器将通过您的 AcmeEvents
类变得不可删除,唯一的另一种可能性是猜测确切的 spl_object_hash()
,因为这是WordPress 创建钩子 ID 的方式。
AcmeEvents::unremovable(Event1::class, Listener::class . '@foobar');
检查监听器的存在
AcmeEvents::hasListeners(Event1::class); AcmeEvents::hasListenersFor( Listener1::class . '@foobar', Event1::class);
以下组合是搜索已注册监听器的有效方式。
[ Listener1::class, '*' ] // will search for Listener1::class + any method [ Listener1::class . '@*'] // will search for Listener1::class + any method [ Listener1::class . '*' ] // will search for Listener1::class + any method [ Listener1::class, 'foobar' ] // will search for Listener1::class + foobar method [ Listener1::class . '@handleEvent'] // will search for Listener1::class + handleEvent method [ Listener1::class, 'handleEvent' ] // will search for Listener1::class + handleEvent method [ Listener1::class] // will search for Listener1::class + handleEvent method
删除事件的监听器
AcmeEvents::forgetOne( Event1::class, Listener1::class . '@foobar');
类和方法组合必须匹配。只有类是不够的。但是,如果您使用默认的 handleEvent()
方法注册了监听器,则可以通过仅传递类名来忘记监听器。
删除事件的监听器
// This will work. AcmeEvents::listen( Event1::class, Listener1::class ); AcmeEvents::forgetOne( Event1::class, Listener1::class ); // This won't AcmeEvents::listen( Event1::class, Listener1::class . '@foobar' ); AcmeEvents::forgetOne( Event1::class, Listener1::class );
自定义标识符
如果您已使用自定义标识符(当使用闭包时总是一个好主意)创建了一个监听器,您也可以通过其自定义键找到并删除监听器。
// This will work. AcmeEvents::listen( Event1::class, [ 'custom_id' => Listener1::class . '@foobar' ] ); AcmeEvents::forgetOne( Event1::class, 'custom_id' ); // This will also work with closures which is impossible with the default WordPress Plugin API AcmeEvents::listen( Event1::class, [ 'closure_key' => function ( Event1 $event ) { // do stuff } ] ); AcmeEvents::forgetOne( Event1::class, 'closure_key' );
内置测试模块
为了在 WordPress 的上下文中进行单元测试,不应需要引导整个 WordPress 核心。现在有两个出色的 WordPress 模拟库
两者都很好,我之前都使用过。然而,感觉使用专门的模拟框架只是为了确保所有代码不会因为 WordPress 核心函数未定义而崩溃并不是很合适。
受到Laravel处理事件测试的方式的启发,BetterWpHooks在第一行代码编写之前就考虑了测试。
您可以使用两种方式与BetterWpHooks一起使用测试模块
1. 完全用伪造的调度器替换底层调度器:使用此选项,您注册的所有监听器都不会被执行。
class OrderTest extends \PHPUnit\Framework\TestCase { /** * Test order shipping. */ public function test_orders_can_be_shipped() { // This replaces the underlying dispatcher instance with a FakeDispatcher AcmeEvents::fake(); // Perform order shipping... // Assert that an event was dispatched... AcmeEvents::assertDispatched(OrderShipped::class); // Assert an event was dispatched twice... AcmeEvents::assertDispatchedTimes(OrderShipped::class, 2); // Assert an event was not dispatched... AcmeEvents::assertNotDispatched(OrderFailedToShip::class); // Assert that no events were dispatched... AcmeEvents::assertNothingDispatched(); } }
您还可以向assertDispatched
或assertNotDispatched
方法传递一个闭包,以断言是否已调度了通过给定“真值测试”的事件。
// create $order AcmeEvents::assertDispatched(function (OrderShipped $event) use ($order) { return $event->order->id === $order->id; });
2. 仅伪造事件子集:如果您正在进行集成测试,但仍然想伪造一些事件(可能是因为它们正在与一个慢速或不稳定的第三方API通信),您可以这样做
- 创建一个继承自
BetterWpHooksTestCase
的测试 - 在测试的
setUp
方法中,调用$this->setUpWp
- 在测试的
tearDown
方法中,调用$this->tearDownWp
BetterWpHooksTestCase
继承自\PHPUnit\Framework\TestCase
,并负责加载所有仅WordPress Hook API。加载WordPress的单一部分是一种危险且脆弱的尝试,这就是为什么我们创建了一个独立的repo,该repo正好反映了WordPress Hook API。这个repo会手动同步每个WordPress版本。
BetterWpHooksTestCase
还会在每次测试前后清理全局状态。
您将获得WordPress Hook API的全部功能,但您的测试仍然会运行得非常快。
假设我们想测试以下方法。
// SUT class OrderProcess { public function processNewOrder ($form_data) { // Do stuff with $form_data OrderCreated::dispatch([$order]); StockStatusUpdated::dispatch([$order]); } } // Registered Listeners $listeners = [ OrderCreated::class => [ // Listeners you want for integration tests ], StockStatusUpdated::class => [ UpdateSlowThirdPartyApi::class, ] ]; // Test class OrderProcessTest extends \BetterWpHooks\Testing\BetterWpHooksTestCase { protected function setUp() : void{ parent::setUp(); $this->setUpWp(); // You need to set up your events and listeners $this->bootstrapAcmeEvents(); } protected function tearDown() : void{ parent::tearDown(); $this->tearDownWp(); } public function test_orders_can_be_processed() { $subject = new OrderProcess(); AcmeEvents::fake([ StockStatusUpdated::class, ]); // All listeners for OrderCreated::class will be called. // UpdateSlowThirdPartyApi::class will NOT be called. $subject->processNewOrder([ // $test_data ]); // Assertions work for both events. AcmeEvents::assertDispatched(OrderCreated::class); AcmeEvents::assertDispatched(StockStatusUpdated::class); } }
$this->bootstrapAcmeEvents();
可以是任何您想要的内容,但如果您想要您的监听器执行,您需要正确地引导您的AcmeEvents
实例。建议您创建一个自定义工厂类,并且在主插件文件中**不要引导您的实例**,这样您可以保持更大的测试灵活性。
它是如何工作的
要了解BetterWpHooks是如何工作的,有必要解释一下核心插件/钩子API是如何工作的。
在基本层面上,您通过add_action()
和add_filter()
添加的所有内容都存储在一个全局变量$wp_filter
中。( ...哎呀 )
许多WP开发者不知道这一点,但add_action()
和add_filter()
实际上是相同的。add_action
函数仅代理到add_filter()
。
当调用do_action('tag')
或apply_filters('tag')
时,WordPress会迭代全局$wp_filter['tag']
关联数组中的每个已注册数组键,并调用已注册的回调函数。
回调可以是
- 匿名函数
[ CallbackClass::class, 'method' ]
组合,其中method
必须是静态的,以避免引起弃用错误。[ new CallbackClass(), 'method' ]
组合,其中处理类已经被实例化。这是最常用的方式,通常与在构造函数中添加钩子结合使用
class CallbackCLass { public function __construct() { add_action('init', [ $this , 'doStuff']); } }
事件是如何分发的
WordpressDispatcher
类负责分发事件。您可以通过您的AcmeEvents
外观访问此类的实例。
这是负责的dispatch
方法的一个简化版本。
public function dispatch( $event, ...$payload ) { // Here we handle mapped events conditionally. if ( ! $this->shouldDispatch( $event ) ) { return; } // Here we convert an event object so that the hook tag is the class name // and the payload is the actual event object // In a sense we just swap $event and $payload. [ $event, $payload ] = $this->parseEventAndPayload( $event, $payload ); // Here we handle temporary removal if a listener wants to stop // a listener chain. $this->maybeStopPropagation( $event ); // If no listeners are registered we just return the default value. if ( ! $this->hasListeners( $event ) ) { if ( is_callable( [ $payload, 'default' ] ) ) { return $payload->default(); } return is_object( $payload ) ? $payload : $payload[0]; } // If we make it this far, only here do we hit the WordPress Plugin API. return $this->hook_api->applyFilter( $event, $payload ); }
正如示例所示,WordPress插件API被使用,但通过一层抽象,BetterWpHooks可以在我们触及插件API之前引入大部分功能。如果条件不满足,我们甚至可能永远都不会触及它(如果条件不满足)
如果所有条件都通过,以下的方法调用
BookingCreated::dispatch([$booking_data]);
将与以下类似
// Traditional Wordpress implementation. $booking = new Booking($booking_data); add_action('acme_booking_created', $booking );
现在,WordPress会调用所有注册的钩子回调函数,这使我们来到
如何调用监听器。
BetterWpHooks充当了你的插件代码与插件API之间的一层。它仍然使用插件API,但方式不同。
根据你在启动过程中定义的,BetterWpHooks在底层创建了3种类型的监听器。
- 闭包监听器
- 实例监听器
- 类监听器
实例监听器和类监听器之间的区别在于,实例监听器已经包含了一个实例化的类(因为你已经传递了它)。
无论创建哪种类型的监听器,它们都被包装在一个匿名闭包中,然后传递给WordPress插件API。
这发生在ListenerFactory
类内部。
/** * Wraps the created abstract listener in a closure. * The WordPress Hook Api will save this closure as * the Hook Callback and execute it at runtime. * * @param \BetterWpHooks\Contracts\AbstractListener $listener * * @return \Closure */ private function wrap( AbstractListener $listener ): Closure { // This anonymous function will be executed by Wordpress // Not the Listener directly. return function ( $payload ) use ( $listener ) { try { return $listener->shouldHandle( $payload ) ? $listener->execute( $payload ) : $payload; } catch ( \Throwable $e ) { $this->error_handler->handle($e); } }; }
WordPress不会直接调用类可调用对象。它只知道匿名闭包,当执行时将执行监听器(如果条件满足)。
这样,我们可以实现对象的延迟实例化,并在WordPress和监听器之间放置一个IoC容器。实际的$listener
构建发生在定义在AbstractListener
类中的execute()
方法中,并且对于每种监听器类型都有所不同。
兼容性
BetterWpHooks与WordPress插件/钩子API的工作方式100%兼容。
- 不修改任何核心文件。
- 不引入自定义事件/观察者模式。动作和过滤器以通常的方式执行。
- 可以在同一网站上由任意数量的插件使用。由于每个插件都通过
BetterWpHooksFacade
特质创建其外观,因此永远不会出现两个插件尝试用调度器或IoC容器做冲突的事情的情况。 - 第三方开发者可以像通常使用
add_action
、add_filter
一样为事件创建自定义钩子。可以说这甚至更容易,因为回调函数只接收一个参数,因此它们不必在文档中查找它们需要使用的参数数量。 - 附加功能:对于高级用户来说,允许删除/自定义钩子非常简单。通常,在WordPress中,要删除使用实例化对象作为钩子回调的钩子非常困难,因为WordPress使用
spl_object_hash()
函数来存储hook_id。这同样适用于闭包。甚至还有专门的包试图解决这个问题,在对象或闭包使用时删除插件钩子。使用BetterWpHooks,对于想要自定义你的插件的用户来说,这变得相当简单。如果你愿意,你可以提供自己的自定义函数来与你的BetterWpHooksFacade
实例交互。例如
if ( ! function_exists('acme_remove_filter') { function acme_remove_filter($tag, $callback) { AcmeEvents::forgetOne($tag, $callback); } } // Third-party dev that wants to customise AcmeEvents acme_remove_filter(Event1::class, Listener1::class); // This works. add_filter(Event1::class, ThridPartyListener::class)
不再访问全局的$wp_filter
或编辑源文件,因为钩子是不可删除的。你也不必像尝试使用remove_filter()
删除钩子时那样记住钩子优先级。
一个注意事项
如果您正在使用这个库,或者任何其他第三方依赖项,在计划在WordPress.org上分发的插件中,存在遇到冲突的风险,当两个插件需要相同的依赖项,但捆绑了不同的版本时。
Composer自动加载器只会加载首先要求的版本。由于WordPress按字母顺序加载插件,如果您的插件依赖于仅在依赖项的新版本中实现的特性,而首先加载的插件需要较旧的版本,则可能会出现问题。
这不是composer或本库的问题,而是由于WordPress在2021年仍未为依赖项管理提供专门的解决方案。
在WordPress找到解决这个问题的方法之前,唯一能确保100%正确的方法是将您插件中拥有的每个依赖项包裹在自己的命名空间中(...又是一次啊)。
然而,有一些项目简化了这一过程
有关此问题的更多信息,请参阅这篇文章,特别是评论部分。
https://wppusher.com/blog/a-warning-about-using-composer-with-wordpress/
待办事项
- 将文档移至专用网站。
- 添加选项,当有人尝试删除无法删除的监听器时,指定自定义错误消息。
- 改进README.md的语法和拼写(我是德国人)- 非常欢迎拉取请求。
贡献
BetterWpHooks是完全开源的,鼓励每个人都通过以下方式参与
- 审查
CONTRIBUTING.md
- ⭐在GitHub上为项目评分(https://github.com/calvinalkan/better-wordpress-hooks)
- 发布错误报告(https://github.com/calvinalkan/better-wordpress-hooks/issues)
- (通过电子邮件将安全问题发送到calvin@snicco.de)
- 发布功能建议(https://github.com/calvinalkan/better-wordpress-hooks/issues)
- 发布和/或回答问题(https://github.com/calvinalkan/better-wordpress-hooks/issues)
- 提交拉取请求(https://github.com/calvinalkan/better-wordpress-hooks/pulls)
- 与您的社区分享对BetterWpHooks的兴奋之情
致谢
Laravel Framework
虽然不依赖于Illuminate/Events包,但BetterWpHooks深受Laravel处理事件派发方式的影响。特别是测试功能与Laravel测试功能非常相似。